Agents that survive the restart.
An agent loop is the least durable thing in your stack. It spends real money, it changes real systems, and by default it keeps all of that in a variable that dies with the process.
Why an agent needs this
Section titled “Why an agent needs this”Take a normal agent loop. Ten turns, a handful of tool calls, running inside generateText.
It works, right up until the pod restarts at turn seven.
At that moment you have paid for seven model calls, and something on the other side of those tool calls has changed: a database was provisioned, a customer was created, a card was charged. None of it is written down anywhere your process can find after it comes back. So the loop starts again from turn one, pays for those seven calls a second time, and calls the same tools again against systems that already did the work.
That is not an edge case. It is a deploy, an OOM kill, a spot instance going away, a SIGTERM during a rolling update.
Three things go wrong, and they are separate problems:
| What breaks | What it costs | |
|---|---|---|
| 1 | The transcript lives in memory | The agent forgets everything and starts over |
| 2 | Completed turns are not recorded | You pay for the same tokens twice |
| 3 | Tool side effects have no inverse | Two databases, two charges, one order |
A durable workflow engine fixes all three, and it fixes them with the same mechanism: write down what happened, one step at a time, before moving on.
What it looks like in practice
Section titled “What it looks like in practice”These numbers come from scripts/ai-sdk/saga-live-e2e.ts, which runs against the live Claude API, kills its own process with a real SIGKILL, and asserts against an external SQLite database that outlives the crash.
Tokens are paid once, not twice. The child process calls the model, the plan is journaled, then the process is killed before it can act on it. A second process calls recover(). The external call log shows the model was called one time.
An agent killed at turn 7 resumes at turn 7. Loop iterations are memoised: completed turns are skipped, and only the turn that was actually interrupted runs again. Before this existed, a four turn loop interrupted once executed seven turns in total. Now it executes five, and the two extra are the genuinely unfinished turn plus the one that finishes the job.
Whatever the model chose gets undone in reverse. In the live run, Claude picked three tools on its own, with its own arguments. A later step failed. The engine destroyed all three, in the exact reverse of the order it had picked them, without knowing the plan in advance.
A real run, not a diagram
Section titled “A real run, not a diagram”This is the unedited output of bun scripts/ai-sdk/saga-live-e2e.ts against the live API. Claude chose the tools, including shards: 3, which nothing in the prompt asked for.
model chose 3 tool call(s): - provision_database({"region":"eu-central"}) - provision_bucket({"region":"eu-central"}) - provision_search_index({"shards":3})
compensation:started apply:2compensation:completed apply:2compensation:started apply:1compensation:completed apply:1compensation:started apply:0compensation:completed apply:0
state failedfailureReason compliance verification rejected the tenantrollbackStatus completed
call log: provision:provision_database-0 provision:provision_bucket-1 provision:provision_search_index-2 destroy:provision_search_index-2 destroy:provision_bucket-1 destroy:provision_database-0
still live: []PASS: provisioned 3, rolled back 3Let the model decide, let the workflow own the effects
Section titled “Let the model decide, let the workflow own the effects”The trick is one line: define your tools without execute. The SDK then hands back the model’s intent instead of running it, and you turn each intended call into a workflow step that has an inverse.
bun add bunqueue ai @ai-sdk/anthropicimport { anthropic } from '@ai-sdk/anthropic';import { generateText, stepCountIs, tool } from 'ai';import { Engine, Workflow } from 'bunqueue/workflow';import { z } from 'zod';
const provisionTools = { provision_database: tool({ description: 'Provision a Postgres database for the tenant.', inputSchema: z.object({ region: z.string() }), }), provision_bucket: tool({ description: 'Provision an object storage bucket.', inputSchema: z.object({ region: z.string() }), }),};
const agentSaga = new Workflow<{ tenant: string }>('provision-tenant') .step('plan', async (ctx) => { const result = await generateText({ model: anthropic('claude-sonnet-5'), tools: provisionTools, stopWhen: stepCountIs(1), messages: [{ role: 'user', content: `Provision infrastructure for ${ctx.input.tenant}.` }], }); return { planned: result.toolCalls.map((c) => ({ name: c.toolName, args: c.input })) }; }, { retry: 2, timeout: 90_000 })
// One compensatable unit of work per tool call the MODEL chose. .forEach( (ctx) => ctx.steps.plan.planned, 'apply', async (ctx) => { const call = ctx.steps.__item as { name: string; args: unknown }; return cloud.create(call.name, call.args, { idempotencyKey: ctx.idempotencyKey }); }, { compensate: async (ctx) => { const call = ctx.steps.__item as { name: string }; await cloud.destroy(call.name, { idempotencyKey: ctx.idempotencyKey }); }, }, )
.step('verify', async (ctx) => compliance.check(ctx.input.tenant), { retry: 1 }) .pivot() .step('send-welcome-email', async (ctx) => mailer.welcome(ctx.input.tenant));The agent decides what to do. The workflow owns the effects. That separation is the whole idea, and it is what makes a non deterministic planner safe to run against production systems.
Read the last two lines carefully. .pivot() marks the point of no return: once the welcome email goes out, a later failure must not release the subdomain the customer has already been told about.
Durable agent turns
Section titled “Durable agent turns”For a real multi turn loop, drive it yourself: one turn per step, so each turn is written down before the next one starts.
const MAX_TURNS = 10;
const agent = new Workflow<{ task: string }>('agent').doUntil( (_ctx, iteration) => iteration >= MAX_TURNS, (w) => w.step('turn', async (ctx) => { const prior: unknown[] = []; for (let i = 0; ctx.steps[`turn:${i}`]; i++) { prior.push(...(ctx.steps[`turn:${i}`] as { messages: unknown[] }).messages); }
const result = await generateText({ model: anthropic('claude-sonnet-5'), tools, stopWhen: stepCountIs(1), messages: [ { role: 'user', content: ctx.input.task }, ...prior, // A restored transcript always ends with an assistant message, which Claude // rejects. Re-open the floor each turn. ...(prior.length > 0 ? [{ role: 'user' as const, content: 'Continue.' }] : []), ], });
return { messages: result.response.messages }; }), { maxIterations: 20 },);The transcript is rebuilt from turn:0, turn:1, turn:2 and so on, which are rows in SQLite, not entries in an array that vanishes with the process. That is the difference between an agent that remembers and an agent that only appears to.
Human approval before something destructive
Section titled “Human approval before something destructive”.step('propose', async (ctx) => askModelWhatToDelete(ctx.input), { compensate: async () => discardPlan(),}).waitFor('human-approval', { timeout: 86_400_000 }).step('execute', async (ctx) => { const decision = ctx.signals['human-approval'] as { approved: boolean }; if (!decision.approved) throw new Error('operator rejected the action'); return performDeletion(ctx.steps.propose);}, { retry: 1 })The run parks. It stops occupying a worker, its state is on disk, and it can sit there for a day. A signal accepted before a crash stays durable. The API is in-process, so after a full outage you first recreate the engine, register the definition and recover; only then can the service accept a new approval.
A rejection is an abort, not a completion, so throwing here unwinds everything the agent did before the gate.
Every action has a stable identity
Section titled “Every action has a stable identity”ctx.idempotencyKey is the same string across every retry of a step, and the same across a crash and resume. Pass it to a provider and a repeat lands on the same operation instead of creating a new one.
await stripe.charges.create({ amount }, { idempotencyKey: ctx.idempotencyKey });This is where most agent implementations quietly lose money. Derive the key from the attempt number and every retry asks for a brand new charge. Derive it from the step, as the engine does, and the provider deduplicates it for you.
Compensate handlers additionally receive ctx.forwardIdempotencyKey, the key the forward call used, so a rollback can ask the provider “did this actually happen?” when the model call succeeded but the response never came back.
When the rollback itself fails
Section titled “When the rollback itself fails”A refund gets refused. A cloud API is down. The engine does not pretend the unwind succeeded, and it does not carry on undoing things whose dependencies are still standing. The run parks in compensation-stuck, and you get two fields that answer two different questions:
exec?.failureReason; // why the run failedexec?.rollbackStatus; // 'stuck', what the engine did afterwards
await engine.resumeCompensation(run.id); // fixed it, finish the unwindawait engine.abandonCompensation(run.id); // accept a partial rollback, explicitly“The provisioning failed” and “the cleanup never ran” need different alerts. An agent platform that collapses them into one status cannot tell you which one woke you up.
What to keep in mind
Section titled “What to keep in mind”Pass ctx.idempotencyKey to every provider call | Stable across retries and crash resume, so a repeat is absorbed rather than duplicated |
Give destructive tools a compensate | The model’s choices are non deterministic, the rollback does not have to be |
Put irreversible actions after .pivot() | An email cannot be unsent, so nothing before it should be undone either |
Raise timeout on model steps | The default is 30s, and a multi turn call routinely exceeds it |
| Keep tool bodies idempotent | Recovery is at least once |
| Set loop length by iteration, not by the model | (_ctx, i) => i >= N is reproducible, “until the model says done” is not |
Verified against the live API
Section titled “Verified against the live API”scripts/ai-sdk/saga-live-e2e.ts runs eight scenarios against the real Claude API, not a mock:
| S1 | Happy path across the pivot, nothing rolled back |
| S2 | Failure before the pivot, unwound in exact reverse of the model’s own choices |
| S3 | Failure after the pivot, zero compensations, work stands |
| S4 | Refused rollback, run parks, operator resumes, books balance |
| S5 | Failure after a real API call, key identical across retries |
| S6 | Real SIGKILL after tokens were paid, resume does not call the model again |
| S7 | Human approval rejected, agent work unwound |
| S8 | Multi turn loop, transcript grows across turns |
ANTHROPIC_API_KEY=... bun scripts/ai-sdk/saga-live-e2e.tsThe offline equivalents run in CI against a mock model, so a regression is caught without spending tokens.
Using a different agent SDK
Section titled “Using a different agent SDK”Everything on this page is written with the Vercel AI SDK, because it is the smallest surface to read. The pattern is not tied to it. For the same saga built on the Claude Agent SDK and the OpenAI Agents SDK, including how to journal an agent session id and how to roll back tools the model chose to call, see Claude & OpenAI Agent SDKs.
Where it differs from Temporal
Section titled “Where it differs from Temporal”Temporal resumes on its own; here recover() is an explicit call you make at
startup. Control-flow choices and completed inner records are journaled, so
loops resume at the interrupted iteration, branches retain their selected path,
and completed parallel siblings short-circuit. Work left running still has an
unknown external outcome and can replay.
What you get in exchange: no cluster, no control plane, no separate service to operate. SQLite, inside your own process, one bun add away.