Copy page
View as Markdown View this page as plain text

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
  },
});
PropertyTypeDescription
descriptionstringOptional 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:

  1. An inbound message arrives on the side’s perspective.
  2. The runtime invokes the handler once with the thread’s ThreadState and that message.
  3. 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 when execute returns. Handlers end sessions explicitly via state.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 blocking child 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 (queueMessage toward 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, or state.notifyParent(...) toward a parent.
  • Respondstate.injectMessage({ role, content }) in its own side’s role (assistant for side A, user for side B — see perspective mapping).
  • Spawn — create child instances of the side’s declared relationships via lifecycle APIs.
  • Controlstate.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 maxSessionTurns on 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.