defer
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() arrives with the payload.
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())
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.
{
"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:
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:
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 }), and meta is how the deferral carries whatever that decision needs:
.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 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.
`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. 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.
What is refused
.defer() is refused at craft() build time where the framework could not revive the exchange, with RC5051:
- 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 withRC5003. 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(), alsoRC5003. 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()) but which configured nodeferralblock fails at startup withRC5052. - An exchange holding anything that is not plain JSON data (a function, a class instance, a
Secret) fails at defer time, withRC5042naming 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.
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). 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('...')).
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, 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 and re-enters the route's error channel with RC5047. 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 (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. A deferral raised from inside an agent tool handler (ctx.defer()) 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). 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
RC5048re-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 withRC5051, carrying the same explanation the build-time refusal gives a static.defer(). - The startup runtime check stays static. A context needs a
deferralblock at startup only for static.defer()(and.resume()) routes; an agent route without one starts fine, and actx.defer()there fails as an ordinary step error withRC5052naming 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 withRC5054; a token presented later readsRC5050.context.stop()is not cancellation: a deferred exchange survives the stop, which is the store's entire purpose.
Related
.resume()-- the other half.- Configuration → deferral -- where deferred exchanges are stored and how tokens are signed.
- Events --
route:exchange:deferred,:resumed,:expired.