Handlers
A handler is a function that takes one side’s turns in a conversation. When a side of an agent is backed by a handler, the runtime does not call a model to answer an inbound message — it calls the handler function once, with the thread state and the message, and whatever that function does is the side’s turn.
A handler is not a hook (hooks decorate turns owned by something else, and their failures are non-fatal), not a tool (tools are capabilities a turn-owner chooses to invoke), and not an effect (effects run outside the conversation flow). Its only role is backing a side of an agent.
1. Handler Definition
Handlers live in their own definition directory (agents/handlers/ in the conventional layout) and are created with defineHandler():
// agents/handlers/relay_pipe.ts
import { defineHandler } from '@standardagents/spec';
export default defineHandler({
description: 'Forwards inbound user traffic to the assistant child and relays answers back.',
execute: async (state, message) => {
// full ThreadState — forward, respond, spawn, stop, read/write KV
},
});
| Property | Type | Description |
|---|---|---|
description | string | Optional human-facing summary |
execute | (state: ThreadState, message: Message) => Promise<void> | The turn implementation |
The message parameter is the full persisted Message — id, role, content, silent flag, metadata, timestamps. It is not a narrowed view: handlers are trusted definition code, like tools.
2. Referencing a Handler
A handler is referenced from exactly one place: the handler property of a side configuration.
defineAgent({
name: 'relay_agent',
sideA: { handler: 'relay_pipe', subagents: [{ agent: 'assistant_agent', maxInstances: 1 }] },
sideB: { human: true },
});
Handlers MUST NOT be referenced by prompt tools arrays, hook lists, or effect schedules. Implementations MUST reject such references at definition time. This keeps the taxonomy crisp — code that wants a capability writes a tool; code that wants to decorate writes a hook; code that owns turns is a handler.
3. Execution Model
A handler-backed side’s turn is:
- An inbound message arrives on the side’s perspective.
- The runtime invokes the handler once with the thread’s
ThreadStateand that message. - The invocation returns (or throws). The turn is complete.
There are no steps, no tool-choice loop, and no model requests for this side. Consequences:
maxSteps,stopOnResponse,stopTool, and session bindings are not legal on handler sides — a handler turn has no steps to cap and ends whenexecutereturns. Handlers end sessions explicitly viastate.stopSession().- When the agent sets
maxSessionTurns, a handler turn counts toward the exchange cap exactly like a prompt turn — exchange counting is uniform across backings. - Runtimes SHOULD record handler turns in their execution/request logs so the turn is observable, attributing failures to the runtime origin rather than a provider.
4. Delivery Semantics
Delivery to a handler is exactly-once per message:
- The runtime MUST invoke the handler exactly once for each inbound message on the side’s perspective, in message persistence order.
- The runtime MUST NOT invoke a handler concurrently for the same thread. Messages that arrive while a handler turn is in flight MUST wait in the queue and be delivered one at a time, in order, after the current turn completes.
- The runtime MUST NOT automatically redeliver a message whose handler invocation failed. Retry is a userland decision (re-send the message).
Warning: Because queued messages wait behind the in-flight turn, a long-running handler stalls its thread. In particular, a handler that awaits a
blockingchild session holds the delivery slot for that session’s entire duration — and if that child can only finish after another inbound message on this thread, the thread deadlocks. Relay-style handlers SHOULD forward without blocking (queueMessagetoward the child) and treat the child’s replies as ordinary later turns.
Cross-thread fan-in ordering (two children queueing toward the same parent handler) is implementation-defined; only per-thread persistence order is guaranteed.
5. Error Semantics
A handler that throws is fatal to the turn, and only the turn:
- The turn is marked failed. The error MUST be surfaced on the runtime’s request/execution log as a runtime-origin failure — a throwing handler never silently swallows a message.
- The inbound message stays persisted. Nothing is rolled back.
- The session continues: the failure does not end the session, terminate the thread, or poison the queue. The next inbound message gets a fresh handler invocation.
This is deliberately unlike hooks (whose failures are non-fatal to the turn they decorate) and unlike session failure (sessionFail / stopSession), which a handler must invoke explicitly if an error should end the session.
6. Capabilities
A handler receives the full ThreadState and may do anything tools can do:
- Forward —
(await state.getChildThread(ref)).queueMessage(...)toward a child, orstate.notifyParent(...)toward a parent. - Respond —
state.injectMessage({ role, content })in its own side’s role (assistantfor side A,userfor side B — see perspective mapping). - Spawn — create child instances of the side’s declared relationships via lifecycle APIs.
- Control —
state.stopSession(),state.setStatus(), effects, environment, key-value store, filesystem.
Handlers need no tool grants for children: the side’s subagents declarations establish existence and lifecycle, and the handler reaches children through ThreadState directly. (The injected lifecycle tools exist for prompts — a handler calls the same operations as code.)
7. The Outbound Boundary
Handlers get no auto-injected reply channel. If a handler (or the side it feeds) should speak to a user or an external surface, that is userland code — a tool or handler calling the messaging surface it chooses (the message_user pattern).
This is a deliberate specification boundary: the runtime routes inbound; userland decides outbound. A runtime-injected “reply” channel was considered and rejected — it would hard-code one outbound topology into the spec and every runtime, where a five-line userland call expresses any of them.
8. Livelock Discipline
Handler sides do not run away on their own — a handler runs only when a message arrives. But a handler that replies to every inbound message paired with a prompt-backed side creates a message ping-pong with no natural stop. Handler authors own their termination discipline:
- reply conditionally, not unconditionally;
- or set
maxSessionTurnson the agent (available on every shape) as a hard safety valve; - or end the session explicitly with
state.stopSession()when the work is done.
9. Worked Example: the Relay
A user-facing thread that does zero model work: every inbound user message is forwarded verbatim to a composed assistant child; every child answer is relayed back out through userland messaging. One handler expresses the entire surface:
// agents/handlers/relay_pipe.ts
export default defineHandler({
description: 'Pipe user traffic to the assistant child; relay answers outward.',
execute: async (state, message) => {
const child = state.children.find(c => c.relationship === 'assistant_agent');
if (child) {
const thread = await state.getChildThread(child.reference);
await thread?.queueMessage({ role: 'user', content: message.content ?? '' });
}
},
});
// agents/agents/relay_agent.ts
export default defineAgent({
name: 'relay_agent',
sideA: {
handler: 'relay_pipe',
subagents: [{
agent: 'assistant_agent',
maxInstances: 1,
immediate: true, // spawned at thread creation — see Subagents §3
parentCommunication: 'explicit',
}],
},
sideB: { human: true },
});
The child’s answers arrive as inbound messages on side A (the owning side) and get their own handler turns, where the handler calls the project’s outbound messaging tool. No model participates at any point.
10. TypeScript Reference
interface HandlerDefinition {
description?: string;
execute: (state: ThreadState, message: Message) => Promise<void>;
}
function defineHandler(options: HandlerDefinition): HandlerDefinition;
Handler names participate in the type registry via HandlerRegistry, so HandlerSide.handler narrows to defined handler names when generation is active.