Skip to content
Get started
Get started
Durable AI Agents with the Vercel AI SDK
guide · workflow engine

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.

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.

ten turn agent, killed at turn 7 ✕ process killed
in memory
journaled
The top track loses every turn and pays for all ten again. The bottom track keeps the six turns it had already written down, and only turn seven runs a second time.

Three things go wrong, and they are separate problems:

What breaksWhat it costs
1The transcript lives in memoryThe agent forgets everything and starts over
2Completed turns are not recordedYou pay for the same tokens twice
3Tool side effects have no inverseTwo 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.

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.

the model picks, the engine unwinds in reverse reverse start order
provision_database provision_bucket provision_search_index
Provisioned left to right, destroyed right to left. The order is taken from the journal, not from timing, so two identical runs unwind identically even when the parallel steps finish in a different order.

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.

bun scripts/ai-sdk/saga-live-e2e.ts
model chose 3 tool call(s):
- provision_database({"region":"eu-central"})
- provision_bucket({"region":"eu-central"})
- provision_search_index({"shards":3})
compensation:started apply:2
compensation:completed apply:2
compensation:started apply:1
compensation:completed apply:1
compensation:started apply:0
compensation:completed apply:0
state failed
failureReason compliance verification rejected the tenant
rollbackStatus 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 3

Let 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.

Terminal window
bun add bunqueue ai @ai-sdk/anthropic
import { 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.

a failure after the pivot undoes nothing committed
reserve-subdomain charge-setup-fee pivot send-welcome-email activate ✕
Nothing is struck through. Past the pivot the saga is committed, so the only correct recovery is forward: retry, alert, fix by hand. Releasing the subdomain of a customer who has already been welcomed would be the worse outcome.

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 until a person answers durable pause
propose waitFor human-approval ← signal({ approved: true }) execute
While it is parked the run holds no worker slot; its durable state is a row, and a timed gate has one lightweight timer while the process is alive. The approval can arrive minutes later or after a redeploy, once the engine is available again.

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.

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.

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 failed
exec?.rollbackStatus; // 'stuck', what the engine did afterwards
await engine.resumeCompensation(run.id); // fixed it, finish the unwind
await 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.

Pass ctx.idempotencyKey to every provider callStable across retries and crash resume, so a repeat is absorbed rather than duplicated
Give destructive tools a compensateThe 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 stepsThe default is 30s, and a multi turn call routinely exceeds it
Keep tool bodies idempotentRecovery 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

scripts/ai-sdk/saga-live-e2e.ts runs eight scenarios against the real Claude API, not a mock:

S1Happy path across the pivot, nothing rolled back
S2Failure before the pivot, unwound in exact reverse of the model’s own choices
S3Failure after the pivot, zero compensations, work stands
S4Refused rollback, run parks, operator resumes, books balance
S5Failure after a real API call, key identical across retries
S6Real SIGKILL after tokens were paid, resume does not call the model again
S7Human approval rejected, agent work unwound
S8Multi turn loop, transcript grows across turns
Terminal window
ANTHROPIC_API_KEY=... bun scripts/ai-sdk/saga-live-e2e.ts

The offline equivalents run in CI against a mock model, so a regression is caught without spending tokens.

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.

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.