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 memoryargs.teamId— team-scoped memoryargs.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-memoryMapon 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-toolsorallowed-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 fromsrc/tools/lookup.tsorsrc/tools/registry.tswithin the library) to control the name prefix, then allow withallowed-tools: "my-hook-*". This function is available to library skills but is not part of the publicagent-appspackage 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.