Pause / Resume
Human-in-the-loop with JSON-checkpointed state. Pause hours mid-run via askHuman or pauseHere; resume on a different process, day, or server.
An agent processes a refund request. Mid-run, the LLM calls
askOperator({ question: 'approve $500 refund?' }). The agent has to wait for a human — could be 30 seconds, could be 3 hours, could be tomorrow. You can't keep the process running. The framework hands you back a JSON checkpoint; you persist it (Redis, Postgres, S3); when the human responds, you resume. Different process, different server — same conversation.
What "pausable" means here
Two halves:
- Pause — a tool calls
pauseHere(...)(or the agent uses the built-inaskHumantool); the framework throws aPauseRequest; the agent loop catches it;agent.run()returns aRunnerPauseOutcomecontaining a JSON-serializablecheckpoint+ the pause data. - Resume — your code persists the checkpoint anywhere; when the human's reply is ready,
agent.resume(checkpoint, humanAnswer)re-builds the state, returns the answer to the paused tool, and continues the agent loop from exactly where it stopped.
The checkpoint is JSON — no functions, no class instances, no closures. Cross-server safe.
A pausing tool
return Agent.create({ provider: provider ?? exampleProvider('feature'), model: 'mock',}) .system('You process refunds. Use askOperator to request approval.') .tool({ schema: { name: 'askOperator', description: 'Ask a human operator for approval.', inputSchema: { type: 'object', properties: { question: { type: 'string' } }, }, }, execute: (args) => { const q = (args as { question: string }).question; // pauseHere throws a PauseRequest; the Agent catches it, // captures the checkpoint, and surfaces a RunnerPauseOutcome // up to whoever called .run(). pauseHere({ question: q, severity: 'high' }); return ''; // unreachable — pauseHere always throws }, }) .build();pauseHere({ question, severity }) throws a special PauseRequest. The agent catches it, captures the checkpoint, and surfaces a RunnerPauseOutcome up to whoever called .run().
The tool's execute looks like it never returns — that's correct. pauseHere always throws. The "return" happens later via resume().
Process A → checkpoint → Process B
// Process A
const result = await agent.run({ message: 'refund order 123' });
if (isPaused(result)) {
// result.checkpoint is JSON-serializable
await db.save('pauses:' + sessionId, JSON.stringify(result.checkpoint));
notifyHuman(result.pauseData);
return; // process A is done
}
// Process B (later, different server, different day)
const checkpoint = JSON.parse(await db.get('pauses:' + sessionId));
const humanAnswer = { approved: true, amount: 500 };
const finalResult = await agent.resume(checkpoint, humanAnswer);
// finalResult is the agent's final string outputBuild the agent fresh in Process B — same factory function, NOT the same instance. The checkpoint is the only thing that crosses the process boundary.
The answer is required
The value you pass to resume() becomes the paused tool's result — it is what the model reads as what the tool said. So resuming with nothing is refused: since 8.18.0, agent.resume(checkpoint) on an askHuman / pauseHere pause raises PauseAnswerRequiredError, naming the tool and both meanings you might have had:
await agent.resume(checkpoint, 'the second option'); // hand the tool an answer
await agent.resume(checkpoint, '(no answer)'); // tell the model nobody answeredThose are different conversations, so the caller picks. Before 8.18.0 the library picked silently and the missing answer became a tool message with no content, which killed the run one turn later with a TypeError naming neither the tool nor the resume. Nothing executes before the refusal and the checkpoint is unchanged — answer it and resume the same one.
A consent gate is different: a tool's checkIn or a middleware's ask is answered with a DECISION (checkInApproved() / checkInDeclined()), and raises DecisionRequiredError when it gets a value instead.
Typed asks — a registered component collects the answer
The question has always been prose. Since 9.24.0 an ask can also carry the typed half: which screen component should collect the answer, and the props it renders with.
component: {
componentId: 'option-picker', // an id YOUR frontend registered
props: { title: 'Pick one' }, // small inline JSON
propsRef: meta.ref, // artifact ref for the big half
}Why ids and props, never markup: the component registry lives in your frontend, so the library (and the model) can nominate a picker without ever being allowed to author one — the same no-eval rule the rest of the ecosystem follows. A screen that does not know the id falls back to the prose question, which still rides every ask.
Why propsRef: the ask rides the checkpoint, and the checkpoint rides every stored session envelope. A 200-option picker's options belong in the artifact store, not in checkpoint freight — the tool mints them via ctx.artifacts.put(...) first, then raises the ask carrying the ~26-char ticket. The screen redeems it through the same artifact wire (head then get) every other ref rides, under the same session scope.
const pickReason = defineTool({ name: 'pick_refund_reason', description: 'Ask the operator to pick the refund reason from the full catalog.', execute: async (_args, ctx) => { // 1. Mint the big half FIRST — 200 options are store freight. const options = Array.from({ length: 200 }, (_, i) => ({ id: `reason-${i}`, label: `Refund reason #${i}`, })); const meta = await ctx.artifacts.put({ kind: 'options/list', mediaType: 'application/json', data: options, label: 'refund reason catalog', }); // 2. The ask CARRIES the ref (validated to resolve, here, at raise). return askHuman({ question: 'Which refund reason applies?', component: { componentId: 'option-picker', // your frontend's registry id props: { title: 'Pick the refund reason' }, // small half, inline propsRef: meta.ref, // big half, by ticket }, }); },});All three ask doors carry it the same way — askHuman({ question, component }) inside a tool, a middleware's ask({ question, component }), and a tool's declared checkInComponent (see Check-in) — and a served pause surfaces it in ONE place, PendingAsk.component, whichever door raised it.
A component that cannot be honored refuses at the source. A propsRef with no store attached, or a ref that does not resolve in the run's own scope, raises InvalidAskComponentError the moment the ask is raised — naming the door and the fix — rather than pausing and letting the person answering discover a dead ref. A consent gate silently downgraded to prose would be worse than a loud refusal.
The answer does not change. The decision posts back through the same structured field it always did (resume(checkpoint, value), checkInApproved() / checkInDeclined()), never parsed from prose. The screen may render the decision as words afterwards — the words are display; the structured decision is the record. What IS new on the record: pause.resume, checkin.decision and the middleware ledger rows now carry the componentId that collected the answer, so the trace says which surface asked.
// ── What the SCREEN does with the typed ask ─────────────────────────const paused = outcome as RunnerPauseOutcome;const ask = paused.pauseData as { question: string; component?: { componentId: string; props?: { title?: string }; propsRef?: string };};console.log(` ❓ ${ask.question}`);console.log(` 🧩 component: ${ask.component?.componentId} (registry decides the renderer)`);// The checkpoint stayed lean — the options are in the store, not in it.const checkpointBytes = JSON.stringify(paused.checkpoint).length;const scope = { conversationId: (JSON.parse(JSON.stringify(paused.checkpoint)) as { sharedState: { runIdentity: { conversationId: string } }; }).sharedState.runIdentity.conversationId,};const redeemed = await artifacts.get(scope, ask.component?.propsRef ?? '');const optionCount = (redeemed?.data as unknown[] | undefined)?.length ?? 0;console.log( ` 🎟 propsRef redeemed: ${optionCount} options from the store; ` + `checkpoint is ${checkpointBytes} bytes and carries none of them`,);// The person clicks. The answer posts back STRUCTURED — the same field it// always was. "Interact-to-NL" is the screen rendering this decision as// words afterwards; the words are display, this value is the record.const decision = 'reason-42';Asks without a component are byte-identical to every earlier release — no new keys on the pause payload, the checkpoint, or the decision events.
The shape and its helpers all ride the main barrel: the AskComponent interface ({ componentId, props?, propsRef? }); readAskComponent(pauseData) — the one reader of a pause payload's component, wherever the pause kind keeps it, for anyone building their own host; assertAskComponent(value, door) — the shape refusal, if you want to validate a component at your own boundary; and InvalidAskComponentError, whose reason field (AskComponentRefusalReason: 'shape' | 'no-store' | 'unresolved-ref') says which rule refused.
When to use pause/resume vs error vs callback
| Situation | Use |
|---|---|
| Long-running approval workflow | pause/resume (this guide) |
| Synchronous tool error → LLM retries | tool throws; see Error handling |
| External webhook → trigger something | pause/resume + webhook handler calls agent.resume() |
| Background task fires multiple times | not an agent — use a queue + per-job agent |
Anti-patterns
- Don't store closures in the checkpoint. The serializer rejects them. Build the agent fresh in Process B from the same factory.
- Don't pass the live agent instance across processes. Pass the checkpoint. The framework rebuilds state from it.
- Don't poll the agent for "is it paused yet?" — the result of
.run()tells you.isPaused(result)is a typed predicate.
Next steps
- Error handling — what's recoverable via retry vs what needs human escalation
- Security guide — permission-gated pauses for sensitive operations
- Reliability guide —
agent.resumeOnError(checkpoint)auto-checkpoints on an uncaught mid-run error (the failure throws aRunCheckpointErrorcarrying a JSON-serializable checkpoint), so any failure becomes resumable, not just intentional pauses.
Deployment
Multi-tenant identity at every store call, peer-dep declarations, mocks-first dev → real-infra prod swap. The patterns that take an agentfootprint app from laptop to production.
Check in with the receipts
Evidence-carrying human consent for consequential tool actions. A tool declares checkIn; the ask rides an evidence pack (willDo / read / drivers / trail); the decision lands as a typed record.
