Advanced
Durable agents
An agent tool handler that cannot answer now defers the whole run through the core deferral store. The loop survives a process restart and continues, mid-conversation, when the resume arrives days later through any ingress.
Deferral is a pipeline primitive, not an agent feature: the store, tokens, TTL, idempotency, and restart semantics are all .defer() / .resume()'s, shared with plain capabilities. The agent tier contributes exactly one thing: what goes into the record's opaque stepState slot, which is the messages thread and outstanding tool-call id that a mid-loop agent is.
One limit before the rest: an agent dispatched with a session cannot defer. A session turn keeps its own transcript and delivers later messages through its inbox, so ctx.defer() inside one fails the turn rather than deferring it. Everything on this page is about the sessionless dispatch.
Deferring from a tool handler
ctx.defer() on the handler context is the whole protocol. Return its sentinel immediately:
agentPlugin({
functions: {
askApproval: {
description: "Ask a human for approval",
input: z.object({ question: z.string() }),
handler: async (input, ctx) => {
await sendApprovalRequest({
question: input.question,
// Mintable BEFORE the deferral, so the message carries a working link.
deferralId: ctx.deferralId,
token: ctx.deferral.token,
})
return ctx.defer({
schema: Approval,
ttl: "72h",
meta: { requires: ["payouts:approve"], fourEyes: true },
})
},
},
},
})
The runtime awaits the batch's in-flight sibling tool calls (their real results persist), stops the loop, and defers the exchange. Execution one replies to the caller with the framework's Deferred acknowledgment; the agent's real AgentResult flows to the route's destinations on execution two.
There is no agent-shaped variant of any of this. ctx.defer() takes exactly what .defer() takes, defers the same record, and is resumed through the same .resume({ authorize }) hook: an agent-raised deferral and a route-raised one are one mechanism, so there is nothing extra to learn and nothing that only half works on one surface.
throw new DeferError({ schema, ttl, meta }) is honoured as an escape hatch for handlers that cannot thread a return value out. Prefer the sentinel: a handler that wraps its work in try/catch silently swallows a thrown deferral and hands the model a garbage tool result, a footgun the returned sentinel cannot reproduce. A DeferError with no schema defers with no declared contract at all, which is the same thing ctx.defer() does when you omit it.
What survives, and how the loop continues
The persisted stepState is { agentId, messages, deferredToolCallId, turnsUsed, usage? }: plain JSON, nothing live. The system prompt, blocks, and tools are deliberately not persisted; the agent step re-runs its full preparation at resume, so inject blocks and lifetime: "context" blocks are re-resolved live, and only resolved strings ever cross the wire (progressive block loads that happened before the deferral are ordinary tool results inside the thread).
On resume, the payload lands as the deferred call's tool result and the loop continues from exactly that point. Two consequences:
maxTurnsand the token spend survive the deferral. A deferral is not a fresh dispatch; a reset would make deferral/resume cycling an unbounded-budget loop, and a cancelled resumed run reports the WHOLE run's spend onAI1005, not just the slice after the resume. A run that resumes with its budget already exhausted fails immediately on the ordinary max-turns path.- One deferral per batch. If two tools in one parallel batch both defer, the first in the model's own emission order wins; the loser's tool result is rewritten to a retryable error ("a sibling already deferred this run") that the resumed model sees and can retry.
One deferral per batch, one credential per call
Every handler in a batch reads the same ctx.deferralId: it names the deferral, and there is exactly one. ctx.deferral.token is different for each of them, and that asymmetry is deliberate rather than a bug to fix. The credential is bound to the tool call that hands it out, so a recipient sent a link by a handler that then LOST the deferral cannot resume the winner's deferral: they take RC5055, and the record is left untouched for the winning call's recipient.
What the binding does not do is make two deferrals out of one. The winning call's deferral is the only one that exists, so a batch that asks two different approvers two different things still has one of them asking about something nobody recorded. Design so at most one tool per turn can defer: one approval tool, or mutually exclusive guards.
Because resume re-enters the agent step itself, the step's own definition is covered by the continuation hash: editing an inline agent's options (system prompt, model, tools) invalidates its deferred runs, refusing the resume with RC5048 so the route can re-ask. A fn handler's body is not covered, though: handlers are resolved by name out of the plugin registry, so editing one does not invalidate deferred runs. That is why meta is the right place for anything a resume must be checked against: it lives on the record, so it cannot drift from what the handler declares today. A by-name agent's registered options are outside the hash (only step definitions are covered); the persisted agentId pins the binding, and a route rebound to a different agent refuses rehydration with AI1007.
The model authored the meta, and validates the payload
meta is composed by a tool handler running inside a model loop that has read whatever untrusted tool output is in its thread. Design the resuming route's hook so meta is never the only thing standing between a caller and an approval.
An agent-raised deferral also cannot re-validate the payload against its schema after a restart: the schema lives in the handler's own code. Revival delivers whatever JSON the resume carried as an ordinary tool result, so a resume-token holder can send the agent arbitrary data. That is the trust level every tool result already has; treat it accordingly in the prompt and in what the loop is allowed to do with it.
Identity is restored, not re-verified: execution two's principal came back from storage, so it is marked restored and any route the resumed agent dispatches into (a Direct(...) tool) refuses it under .authorize() until a live credential re-verifies it. The principal system-prompt block and tool guards render and read the restored shape as data; authorization is what refuses it.
The resumed loop therefore runs as the principal that DEFERRED it, never as the resuming one. The resuming route's hook gates who may inject the payload; that principal is recorded as resumedBy and gains none of the run's authority. Post-approval authorization that needs a live credential belongs on the resume ingress route, where the resuming principal is verified.
Rewriting a deferred thread
A run that defers for a human approval can wait days, and a thread that was comfortably inside the model's context window when it deferred may not be when the approver finally clicks. The only moment to shrink it is while the run is still deferred, because a resume lands on the thread as it stands and has nowhere else to go.
replaceDeferredThread does that swap safely:
import { replaceDeferredThread } from "@routecraft/ai";
const result = await replaceDeferredThread(store, deferralId, async (messages) =>
summariseOldTurns(messages),
);
Two things happen before the store is written. The rewrite is put through
assertResumableThread, which refuses a thread that broke tool-call /
tool-result pairing, reordered a result before its call, duplicated a call id,
emptied itself, or dropped the deferred call the approver's answer lands on,
with AI1008. This guard is the point: a
summariser asked to shorten a conversation will happily drop half a pair, and
without the check the damage surfaces only when the approver clicks, by which
point the approval is spent and the run is unrecoverable. Refused here instead,
it costs nothing.
Then the write goes through the store's replaceStepState compare-and-swap
against the state the rewrite was based on, so a resume, a sweep, or a second
rewrite that got there first wins and this one reports won: false rather than
overwriting it. Losing is an ordinary outcome, not an error. What the loser
should do next is in the returned deferral: another compaction may have
won, or the record may have left the deferred state entirely.
Only messages changes. turnsUsed in particular is left alone, because
shrinking the conversation does not give the run its budget back.
To decide whether a compaction is needed at all, catch
AI1009, or test an error with
isContextOverflow() from @routecraft/ai.
Where deferral is refused
- Outside an agent dispatch on a route-bound exchange (
AI1006): a proxied MCP tool guard,testFn, or an agent invoked over a synthetic exchange. The refusal happens at thectx.defer()call; nothing is written. - In a context with no
deferralblock (RC5052): agent routes do not require the runtime at startup (most agents never defer), so the deferral itself fails as an ordinary step error naming the one config line. - Inside a
.split()fan-out or a.multicast()/.dispatch()side flow (RC5051): the route builds (whether an agent defers is dynamic), and the first actual deferral from such a position is refused with the same explanation a static.defer()gets at build time.
Cancellation
A cancelled run is terminal and honest about its spend: the abort signal (route stop, a forced shutdown, or a route-scope .timeout()) is observed between turns, inside the model call, and inside tool dispatch, and the run fails with AI1005 carrying turns completed and tokens spent. Cancellation racing the deferral itself refuses or denies the deferral with RC5054, so a run whose caller was told it failed never leaves a live resume link.
context.stop() is not cancellation of deferred work: a deferred agent survives the stop and the restart, which is the point.
Over MCP
A tool whose route dispatches an agent may defer, so if it declares .output() its advertised outputSchema is the derived union oneOf: [Output, Deferred], and a deferral replies with the acknowledgment in structuredContent on an ordinary isError: false result, emitting plugin:mcp:tool:deferred. On the 2025 protocol revision the advertisement and the acknowledgment both carry the { result: ... } envelope. See Running an MCP server.
Related
.defer()and.resume(): the primitive this rides on, including the crash boundary (durability starts at a declared deferral, never mid-step).- agentPlugin: registering the fns whose handlers defer.
- Events:
route:exchange:deferred/:resumed/:expiredfire for agent defers exactly as for route defers; the agent tier adds no parallel event set.