# defer

[← All operations](/docs/reference/operations)

```ts
defer(options: {
  schema?: StandardSchemaV1
  ttl?: Duration
  meta?: JsonValue
}): RouteBuilder<Current>
```

Defer the exchange durably and exit the pipeline. The run that reaches a `.defer()` ends there and replies immediately; the exchange continues from the next step later, when [`.resume()`](/docs/reference/operations/resume) arrives with the payload.

```ts
craft()
  .id('payout')
  .input({ body: PayoutRequest })
  .from(http({ path: '/payouts', method: 'POST' }))
  .choice(
    when(
      (ex) => ex.body.amountCents >= 50_000,
      (b) =>
        b
          .tap(direct('notify-approver'))
          .defer({ schema: Approval, ttl: '72h' })
          .filter((ex) =>
            ex.deferral.result.approved
              ? true
              : { reason: `rejected by ${ex.deferral.resumedBy?.subject}` },
          ),
    ),
  )
  .transform((payout) => executePayout(payout))
  .to(log())
```

| Option | Type | Default | Required | Description |
|--------|------|---------|----------|-------------|
| `schema` | `StandardSchemaV1` | -- | No | What a valid resume payload looks like. Types `ex.deferral.result` for every step after the defer, and is what the submitted payload is validated against at resume time. Omit to declare no contract: nothing is validated at the ingress and `ex.deferral.result` types `unknown`. |
| `ttl` | `Duration`: milliseconds, or `"<n><unit>"` with unit `ms` / `s` / `m` / `h` / `d` | [`deferral.defaultTtl`](/docs/reference/configuration#deferral), itself `72h` | No | How long the deferral stays resumable, for example `"500ms"`, `"30s"`, `"72h"`, `"7d"`. Omit to inherit the context default. A deferral with no deadline at all needs `defaultTtl: 'never'` on the context, because a deferred exchange nothing will ever retire is a leak rather than a default. A malformed value fails at the call site with `RC5003`, on `.defer()` and `ctx.defer()` alike; code that computes a ttl can validate it under the same rules with the exported `parseDuration(value, field)`. |
| `meta` | plain JSON | -- | No | Anything the resuming route needs to decide who may resume, or an operator needs to read off the record. Persisted verbatim, never interpreted, and never surfaced on the acknowledgment. See [Who may resume](#who-may-resume). |

> **Warning: There is no approve or deny**
>
> A deferral is a durable pause with one single-use payload slot of arbitrary shape. The framework has no approve/deny concept, does not read the payload, and attaches no meaning to it: this route's own continuation interprets whatever arrives, which is why the shipped example ends in a `.filter()`. `schema` describes the slot; it does not make a payload an approval, and the framework never reads it to decide anything.

## Execution one always replies

A durable defer cannot hold a caller: the resume payload arrives in hours or days and the process will be restarted first. So the run terminates at the defer and returns a `Deferred` value instead of the route's declared output.

```jsonc
{
  "status": "deferred",
  "deferralId": "3f1c…~0",
  "token": "eyJ2Ijox…",       // signed, single use
  "schema": { "type": "object", … },  // when a declared schema renders one
  "expiresAt": "2026-08-13T09:00:00.000Z"
}
```

That is the whole acknowledgment. It carries the CONTRACT (what to send back, where, and until when) and nothing else: everything policy-shaped lives on the record, because the acknowledgment crosses the wire to whoever called the route, including the party a resume hook exists to judge.

A route with a reachable defer therefore has output type `Output | Deferred`. Each source renders that its own way:

| Source | Rendering |
|--------|-----------|
| `http()` | `202 Accepted`, the `Deferred` value as the body, `Retry-After` from the `ttl` |
| `mcp()` | an ordinary `isError: false` tool result whose `structuredContent` is the acknowledgment; a tool that declares `.output()` and can defer advertises `oneOf: [Output, Deferred]`, and on the 2025 protocol revision both sides arrive inside the `{ result: ... }` envelope (see [Running an MCP server](/docs/advanced/expose-as-mcp#deferrable-tools)) |
| `direct()` | the value itself; the caller narrows with `isDeferred(result)` |
| `cron()`, `simple()`, file | nothing on the wire, a log line only; completion is simply deferred |
| queue sources | ack, never nack: the work is deferred in the deferral store, and a redelivery would ask the approver twice |

The route's real output flows to its destinations on execution two, not back to the original transport.

## Everything else is existing grammar

`.defer()` stays small because most of what a defer appears to need is already a verb in the DSL:

| Concern | Where it goes |
|---------|---------------|
| Defer only sometimes | a `.choice()` branch contains the defer |
| Notify the approver | ordinary steps before it, e.g. `.tap(direct('notify-approver'))`. `ex.deferral.token` is readable BEFORE the defer runs, so the message can carry a working link |
| Handle a rejection | the last step of the branch consumes the verdict, e.g. `.filter()` |
| Authenticate and authorize the resuming principal | the resume ingress route: `.authenticate()` for who they are, [`.resume({ authorize })`](/docs/reference/operations/resume#securing-resume) for whether they may resume. `meta` here carries whatever that decision needs |
| Expiry handling | `ttl` plus a route-scope `.error()`; the sweeper retires an overdue deferral on its own and re-enters that channel with [`RC5047`](/docs/reference/errors#rc-5047) |

## Who may resume

The framework does not define how approvals work. It has no notion of an approver, a role, a four-eyes rule, or an escalation: a deferral is a durable pause with one single-use payload slot, and what makes a resuming principal legitimate is your design, not ours.

What it does guarantee is the part you cannot build from outside:

- The token addresses exactly one deferred record and is single-use.
- The claim is a compare-and-swap, so one resume wins and late or duplicate ones read the settled outcome.
- **Every refusal happens before that claim**, so saying no costs the rightful principal nothing.
- Both principals and the record are handed to your code at the one moment deciding is free.

So the decision lives on the resuming route, in [`.resume({ authorize })`](/docs/reference/operations/resume#securing-resume), and `meta` is how the deferral carries whatever that decision needs:

```ts
.tap(direct('notify-approver'))
.defer({
  schema: Approval,
  ttl: '72h',
  meta: { channel: 'finance', requires: ['payouts:approve'], fourEyes: true },
})
```

`meta` is plain JSON under the same rule as the exchange body ([`RC5042`](/docs/reference/errors#rc-5042) for anything that cannot round-trip), persisted verbatim, and never read by the framework. Because it lives only on the record, a defer site that snapshots its policy there gets **policy travels with the deferral** by construction: the resuming hook reads the record, so editing this site changes nothing for records already deferred.

> **Warning: `meta` is authored by the deferring step**
>
> On the agent surface that step is the MODEL, and the model has read whatever untrusted tool output is in its thread. Route on it, log it, render it. Do not let it be the only thing standing between a caller and an approval.

The continuation always runs as the **deferred** principal, restored from storage and [marked restored](/docs/reference/operations/resume#securing-resume). The resuming hook gates who may inject the payload and receive the result; it never changes whose authority the rest of the route executes under.

## The branch-rejoin rule

A defer branch rejoins the main flow only if it restored the main flow's contract; otherwise it must leave the flow entirely (drop, or complete). The body crosses the deferral untouched, so the fast path and the approved path are indistinguishable downstream, and `ex.deferral` is never read by the main flow.

## ex.deferral

`ex.deferral` is readable anywhere in a pipeline.

| Field | Available | Description |
|-------|-----------|-------------|
| `id` | always | The deferral id this exchange would defer as. |
| `token` | always | Signed, single-use resume token for that id. Mintable before the defer runs, which is what makes a notification step useful. |
| `tokenFor(call)` | always | A credential bound to one call on the record. Only a step that can raise several calls against one deferral needs it; see [Durable agents](/docs/advanced/durable-agents). |
| `sequence` | always | How many times this exchange has already deferred. |
| `result` | after a resume | The validated payload, typed by `schema`. `unknown` when the site declared none. |
| `resumedBy` | after a resume | Who resumed it, when the resume ingress had an authenticated principal. |
| `resumedAt` | after a resume | When the resume was accepted. |

## What is refused

`.defer()` is refused at `craft()` build time where the framework could not revive the exchange, with [`RC5051`](/docs/reference/errors#rc-5051):

- **Inside `.split()`.** A durable aggregator would have to track N outstanding children across restarts. Split the work into per-item child capabilities instead: each is its own exchange and defers independently.
- **Inside a `.multicast()` path or a `.dispatch()` target.** Those exchanges are isolated side flows, so a resumed continuation would have nowhere to rejoin.
- **Under a step-scope wrapper** (`.retry()`, `.timeout()`, `.cache()`, …), which fails with [`RC5003`](/docs/reference/errors#rc-5003). Deferring is not a failure to re-attempt. Put `.error()` at route scope instead, where it also catches revival failures.
- **On a route with route-scope `.cache()`**, also [`RC5003`](/docs/reference/errors#rc-5003). The cache filters wrap the user pipeline, which a deferral exits and a resume re-enters partway down, so neither the check nor the store would ever run and the cache would silently do nothing. Use a step-scope `.cache()` on the expensive step instead.

Two more refusals happen outside build time, each as early as it can be known and both well before a resume:

- A context whose routes can reach a defer (or a [`.resume()`](/docs/reference/operations/resume)) but which configured no [`deferral`](/docs/reference/configuration#deferral) block fails at **startup** with [`RC5052`](/docs/reference/errors#rc-5052).
- An exchange holding anything that is not plain JSON data (a function, a class instance, a `Secret`) fails at **defer time**, with [`RC5042`](/docs/reference/errors#rc-5042) naming the offending path. Deliberately at the deferral rather than at the resume: the deploy that introduced the value is what should fail, not the approver's click days later.

> **Warning: Durable from the defer point onward**
>
> Deferral guarantees durability from a **declared** defer point. It is not general crash recovery: if the process dies at step 4 of a route that never reached a `.defer()`, that exchange is gone. Routecraft does not checkpoint at every step boundary.

## Resilience on the continuation

A resume runs the continuation inside a chain **rebuilt** from the positions that survive a deferral, in their usual order ([pre-from filter chain](/docs/advanced/filter-chain#a-resumed-exchange-re-enters-partway-down-the-chain)). Route-scope `.error()`, `.retry()`, `.timeout()` and `.concurrency()` apply to execution two; `authorize`, `parse`, `input`, `throttle` and `circuitBreaker` do not, because they describe an exchange arriving at the route and this one arrived once. `cache` is refused at build alongside a reachable defer. Of the positions that stay off, `throttle` and `circuitBreaker` have step-scope forms you can declare inside the continuation, where they bound the step rather than the arrival. `authorize`, `parse` and `input` have no step-scope equivalent, because each describes an exchange entering a route: work that needs one of those belongs on a route that has its own chain, reached with `.to(direct('...'))`.

> **Warning: Route-scope `.retry()` reaches the continuation**
>
> Because `.retry()` applies to execution two, the steps after a `.defer()` are at-least-once on failure, the same as the steps before it. If your continuation does something a downstream cannot absorb twice, make it idempotent or move it behind a step-scope wrapper you control.

Two limits are worth knowing before you rely on this for money:

**A resume is spent whether or not the continuation ultimately succeeds.** `.resume()` wins the store's compare-and-swap before running anything, so once retries and the deadline have settled, a continuation that still fails records a `failed` continuation result and a second resume with the same token receives that cached failure rather than a second run. This is what makes "did this already run?" answerable across a restart. A route-scope `.retry()` absorbs the transient case; what it cannot absorb needs a `.error()` handler that re-asks, not a re-click from the approver.

**A process that dies mid-continuation does not resume itself.** The record reads settled with a `resumed` outcome and no continuation result, and nothing re-drives it; a later resume with the same token is told the first resume never recorded one. Recovering that automatically needs a lease on the resumed outcome, which is not implemented: re-running a continuation whose side effects may have half happened is not something the framework can decide for you. What you do get is that the next startup counts these records and warns, so they are visible rather than silent. Treat one as needing an operator, and keep continuations short where the work is not idempotent.

**Expiry fires within a sweep interval of the deadline, not on it.** A background sweeper retires overdue deferrals on a schedule ([`sweepInterval`](/docs/reference/configuration#deferral), 60 seconds by default), so a "nobody approved in 72 hours, escalate" flow runs whether or not anyone ever clicks the link. It also scans at startup, before the context reports ready, so whatever came due while the process was down reaches its routes ahead of new traffic. What it does not give you is a deadline honoured to the second: a deferral is retired on the first sweep after its `ttl` elapses.

Retiring a deferral emits [`route:exchange:expired`](/docs/reference/events) and re-enters the route's error channel with [`RC5047`](/docs/reference/errors#rc-5047). The sweeper competes for the same transition a late resume competes for, so an approval landing on the deadline is either accepted or expired, never both, and only the winner notifies.

**Expiry notification is at-least-once, not exactly-once.** Delivery is claim-then-notify-then-finalize, so a process that dies mid-delivery leaves a claim the sweeper releases after [`expiryLease`](/docs/reference/configuration#deferral) (60 minutes by default) and redelivers: the approver hears about the expiry despite the crash. The accepted cost is the other crash window; a process that dies after notifying but before finalizing redelivers one duplicate escalation once the lease elapses. Make the `.error()` re-ask path tolerant of a repeat, the way any notification handler should be.

**An agent-raised deferral accepts any JSON payload.** A `.defer({ schema })` on a route validates the payload against the live schema read back off the route, refusing a mismatch with [`RC5049`](/docs/reference/errors#rc-5049). A deferral raised from inside an agent tool handler ([`ctx.defer()`](/docs/advanced/durable-agents)) cannot be re-validated after a restart: its schema lives in the handler's own code, so revival delivers the raw payload as an ordinary tool result and the model is the validator. A resume-token holder can therefore feed the agent arbitrary JSON, which is the same trust level every tool result already has; the schema rendering on the acknowledgment is guidance for the caller, not enforcement.

## Deferral raised from a step

`.defer()` is one of two ways an exchange defers. The other is a **defer-capable step** raising a deferral from inside its own execution; the shipped case is the agent step, whose tool handlers defer through `ctx.defer()` (see [Durable agents](/docs/advanced/durable-agents)). The differences are worth knowing:

- **Resume re-enters the step itself**, not the step after it: the step must finish the work it deferred in the middle of. Its own definition is therefore covered by the continuation hash, so editing an inline agent's options (system prompt, model, tools) invalidates its deferred runs through the [`RC5048`](/docs/reference/errors#rc-5048) re-ask path, exactly as editing a continuation step does.
- **Positional refusals move to runtime.** Whether a capable step ever defers is dynamic, so a route that fans an agent out over a `.split()` or into a `.multicast()` path builds fine; the first actual deferral from such a position is refused with [`RC5051`](/docs/reference/errors#rc-5051), carrying the same explanation the build-time refusal gives a static `.defer()`.
- **The startup runtime check stays static.** A context needs a [`deferral`](/docs/reference/configuration#deferral) block at startup only for static `.defer()` (and `.resume()`) routes; an agent route without one starts fine, and a `ctx.defer()` there fails as an ordinary step error with [`RC5052`](/docs/reference/errors#rc-5052) naming the one config line.
- **A cancelled run cannot leave a live link.** A run aborted around its own deferral (an elapsed route-scope `.timeout()`) refuses to defer, or immediately denies the just-written deferral, failing with [`RC5054`](/docs/reference/errors#rc-5054); a token presented later reads [`RC5050`](/docs/reference/errors#rc-5050). `context.stop()` is not cancellation: a deferred exchange survives the stop, which is the store's entire purpose.

## Related

- [`.resume()`](/docs/reference/operations/resume) -- the other half.
- [Configuration → deferral](/docs/reference/configuration#deferral) -- where deferred exchanges are stored and how tokens are signed.
- [Events](/docs/reference/events) -- `route:exchange:deferred`, `:resumed`, `:expired`.
