# 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()`](/docs/reference/operations/defer)'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](/docs/reference/adapters/agent#sessions-an-agent-that-remembers) 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:

```ts
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.

| `ctx.defer` option | Required | Description |
|---------------------|----------|-------------|
| `schema` | No | What a valid resume payload looks like. Rendered onto the acknowledgment for the caller; see the trust note below for what it does not do. |
| `ttl` | No | How long the deferral stays resumable. Defaults to the context's [`defaultTtl`](/docs/reference/configuration#deferral); expiry re-enters the route's error channel with [`RC5047`](/docs/reference/errors#rc-5047). |
| `meta` | No | Anything the resuming route needs to decide [who may resume](/docs/reference/operations/resume#securing-resume), persisted verbatim on the record and never surfaced on the acknowledgment. |

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:

- **`maxTurns` and 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 on [`AI1005`](/docs/reference/errors#ai-1005), 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.

> **Warning: 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`](/docs/reference/errors#rc-5055), 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`](/docs/reference/errors#rc-5048) 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`](/docs/reference/errors#ai-1007).

> **Warning: 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](/docs/reference/operations/resume#securing-resume) 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:

```ts
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`](/docs/reference/errors#ai-1008). 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`](/docs/reference/errors#ai-1009), or test an error with
`isContextOverflow()` from `@routecraft/ai`.

## Where deferral is refused

- **Outside an agent dispatch on a route-bound exchange** ([`AI1006`](/docs/reference/errors#ai-1006)): a proxied MCP tool guard, `testFn`, or an agent invoked over a synthetic exchange. The refusal happens at the `ctx.defer()` call; nothing is written.
- **In a context with no [`deferral`](/docs/reference/configuration#deferral) block** ([`RC5052`](/docs/reference/errors#rc-5052)): 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`](/docs/reference/errors#rc-5051)): 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](/docs/reference/configuration#shutdown), or a route-scope `.timeout()`) is observed between turns, inside the model call, and inside tool dispatch, and the run fails with [`AI1005`](/docs/reference/errors#ai-1005) carrying turns completed and tokens spent. Cancellation racing the deferral itself refuses or denies the deferral with [`RC5054`](/docs/reference/errors#rc-5054), 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](/docs/advanced/expose-as-mcp#deferrable-tools).

## Related

- [`.defer()`](/docs/reference/operations/defer) and [`.resume()`](/docs/reference/operations/resume): the primitive this rides on, including the crash boundary (durability starts at a declared deferral, never mid-step).
- [agentPlugin](/docs/reference/plugins/agentplugin): registering the fns whose handlers defer.
- [Events](/docs/reference/events): `route:exchange:deferred` / `:resumed` / `:expired` fire for agent defers exactly as for route defers; the agent tier adds no parallel event set.
