Runtime Skills

Session

Metadata middleware that gives any skill persistent conversational memory. Correlates invocations by a configurable key, captures data per invocation, and re-injects history into the prompt automatically.

Setup

Add session to a skill's metadata with a dotted path into ctx that resolves to the correlation ID:

# Shorthand — captures everything, in-memory storage
metadata:
  session: args.sessionId
# Full form — team-scoped, file-backed, selective capture
metadata:
  session:
    key: args.teamId
    capture: [$args, $result]
    maxEntries: 100
    store: file

Configuration

Field Type Default Description
key string (required) Dotted path into ctx to resolve the correlation ID
capture string[] [$all] What to persist per invocation
maxEntries number 50 Rolling window — oldest entries dropped when exceeded
store string memory Storage backend — memory (process-lifetime) or file (persists to .agent-apps/sessions/)

Key Resolution

The key is resolved via pathGet(ctx, path). Since args lives on ctx, any arg is reachable:

  • args.sessionId — HTTP sessions (serve already passes this from cookies)
  • args.userId — user-scoped memory
  • args.teamId — team-scoped memory
  • args.route.id — per-resource (e.g., per-topic)
  • nonlocals.request.headers.x-tenant-id — HTTP header

If the key resolves to undefined, the middleware skips gracefully — no session for that invocation.

Capture

Each entry in capture is either a $-prefixed sentinel or a dotted path into ctx:

Entry Captures
$all args, result, and locals.agent.trace (default)
$args ctx.args
$result ctx.locals.result
$agent ctx.locals.agent.trace (agent conversation trace)
args.message A specific arg field
result.summary A specific result field
locals.someData Anything on context

Prompt Injection

For markdown skills, session history is auto-prepended to the prompt with a static preamble explaining the convention. For code skills (no prompt), the history is available on ctx.locals.session.

Directive Placement

To control where history appears in the prompt, use the :session-history[] directive:

metadata:
  session: args.sessionId
---
You are a helpful assistant.

:session-history[]

Answer based on the conversation above.

When :session-history[] is present, the middleware skips auto-prepending and lets the directive handle placement.

Storage

  • memory (default) — in-memory Map on globals. Process-lifetime. Best for long-lived processes (serve, persistent agent).
  • file — JSON files in {cwd}/.agent-apps/sessions/. Survives process restarts. Best for CLI and stateless invocations.

Web Integration

The web extension already passes sessionId in args from HTTP cookies. A route skill with session: args.sessionId gets conversational memory with zero additional wiring.

Comment Stripping

HTML comments (<!-- ... -->) are stripped from prompts before directive processing. Comments inside fenced code blocks are preserved. This lets you add developer notes that the agent never sees:

<!-- TODO: Add error handling for payment failures. -->
<!-- This skill handles the checkout flow. -->

Process the checkout for :arg[orderId].

Observing Gateway Messages

Transport middleware (email, Slack, WebSocket, webhook) emit gateway-message events when inbound messages arrive. These events are broadcast — they do not affect route dispatch or gateway-receive. Use them to observe traffic without consuming it.

// Wait for the next inbound message on any transport
const events = await ctx.manager.invoke('event-wait', { topic: 'gateway-message', timeout: 60000 });
// events[0].source = gateway name, events[0].data = { from, subject, body } (email) etc.

For buffered observation (no missed events between waits), subscribe first:

await ctx.manager.invoke('event-subscribe', { topic: 'gateway-message' });
// ... events accumulate in the queue even when you're not waiting ...
const events = await ctx.manager.invoke('event-wait', { topic: 'gateway-message', timeout: 60000 });

Dispatch vs. Receive vs. Events

Transports have three independent delivery paths for inbound messages:

Path Mechanism Consumption
Route dispatch Transport middleware matches routes: config, invokes the skill One skill per message (first match)
gateway-receive Skill calls gateway-receive → transport's receive skill polls a shared queue One consumer per message (queue take)
gateway-message events Broadcast event emitted by the transport Non-consuming — all subscribers see every event

Route dispatch and gateway-receive are independent consumers. If a skill is invoked by route dispatch and also calls gateway-receive, both paths deliver messages — the same inbound message appears in the dispatch (triggering the skill) and in the receive queue (available to gateway-receive). This is by design: dispatch handles routing, receive handles imperative reads, and they do not interfere with each other.

For full control over which messages you see and when, use event-wait on the gateway-message topic. Events are broadcast to all subscribers, support ack: false for peek-without-consume, and work with named consumer groups for independent observation.

Callback Refs

Remote request/response pattern for cross-boundary communication. The agent uses these internally for invokeRef and hookRef, but they're available for any use case.

Skill Description
callback-create Create a callback endpoint. Returns a ref that can be invoked like any skill.
callback-wait Wait for the next pending invocation on a callback. Returns { id, args }.
callback-respond Deliver a result to a pending invocation, unblocking the caller.
callback-release Release a callback endpoint, cleaning up state.
// Create a callback
const { ref } = await ctx.manager.invoke('callback-create', { timeout: 30000 });

// Pass ref to another process/skill — they invoke it like any skill
// Meanwhile, wait for invocations:
const invocation = await ctx.manager.invoke('callback-wait', { ref });
// Process invocation.args, then respond:
await ctx.manager.invoke('callback-respond', { ref, id: invocation.id, result: 'done' });

// Clean up
await ctx.manager.invoke('callback-release', { ref });

Callbacks and allowed-tools

Callback refs are registered as programmatic tools with generated names in the form {label}-{8-char-uuid} (e.g., callback-a1b2c3d4). They are subject to allowed-tools enforcement like any other tool. If a skill declares allowed-tools, the callback name must match the spec for the invocation to succeed.

Three ways to allow callbacks:

  • No allowed-tools or allowed-tools: "*" — everything is allowed, callbacks included.
  • allowed-tools: "callback-*" — allows any callback created with the default label.
  • Custom label — use createCallbackRef(ctx, fn, { label: 'my-hook' }) (imported from src/tools/lookup.ts or src/tools/registry.ts within the library) to control the name prefix, then allow with allowed-tools: "my-hook-*". This function is available to library skills but is not part of the public agent-apps package export.

State

Key-value persistent state for skills. Two backends: file (default, persists to .agent-apps/state/ as JSON files) and memory (process-lifetime Map on globals). All skills accept an optional store argument to choose the backend.

Skill Description
state-get Read a value by key. Returns null if not found.
state-set Write a value by key. Overwrites existing. Notifies state-wait listeners on write.
state-delete Delete a key. Returns { ok, existed }.
state-list List all keys, optionally filtered by prefix.
state-wait Block until a key exists. Returns immediately if already set, otherwise waits up to timeout ms (default 60000).
// Write and read
await ctx.manager.invoke('state-set', { key: 'user:alice:prefs', value: { theme: 'dark' } });
const prefs = await ctx.manager.invoke('state-get', { key: 'user:alice:prefs' });

// List keys by prefix
const userKeys = await ctx.manager.invoke('state-list', { prefix: 'user:alice:' });

// Wait for a key (useful for cross-skill coordination)
const result = await ctx.manager.invoke('state-wait', { key: 'job:123:done', timeout: 30000 });

// Delete
await ctx.manager.invoke('state-delete', { key: 'user:alice:prefs' });

// Use memory backend for process-lifetime state
await ctx.manager.invoke('state-set', { key: 'counter', value: 42, store: 'memory' });

File-backed state persists across process restarts. Memory-backed state is faster but lost on exit. Use state-wait for coordination between concurrent skills — one skill sets a key, another waits for it.

Ask AI