Durable Agents
Your agent works great on localhost. In production it dies halfway through step 14, loses everything, and the user gets a spinner forever. This guide covers why that happens and the three Vercel building blocks that fix it: durable workflows Durable Workflow A multi-step process that survives crashes, timeouts, and deploys: each completed step is saved, so a retry resumes where it left off instead of starting over. Ideal for long AI agent runs and anything that waits for humans or webhooks. Vercel Workflow uses `'use workflow'` and `'use step'` directives. "Like a video game with autosave at every checkpoint. If the power goes out, you respawn at the last checkpoint, not level one." Message Queue A buffer where one part of your system drops jobs ("send this email", "process this upload") and workers pick them up later, with retries if something fails. Queues smooth out traffic spikes and keep slow work out of the request. On Vercel, Queues (in beta as of Sep 2026) provide this via `@vercel/queue`. "Like the ticket rail in a restaurant kitchen. Orders pile up in sequence and cooks take the next one when they're free." Sandbox An isolated, throwaway environment where untrusted code (for example, code an AI just wrote) can run without touching your real machine, data, or secrets. Vercel Sandbox provides these as Firecracker microVMs you control from code with `@vercel/sandbox`. "Like a padded test room. Let the new robot swing its arms around in there, not in your living room."
1Why serverless functions die mid-agent
A route handler on Vercel is a serverless Serverless A cloud model where you don't manage servers: your code runs in response to requests or events, scales automatically, and you pay for usage. Modern platforms like Vercel's Fluid Compute reuse warm instances for many requests at once and bill for active CPU time, which makes serverless a much better fit for slow AI calls. "Like renting a kitchen by the meal instead of buying a restaurant. Use it, pay for it, done."
It runs out of clock
20 tool steps at 20 to 40 seconds each blows past any function limit.
It forgets everything
A crash, a provider 529, or a deploy mid-run, and all progress lives in memory that just vanished.
It can't wait
"Ask the manager, continue when they approve" could take two days. No request stays open that long.
2Mental model: save points in a video game
A durable workflow is a game with autosave. Each step is a checkpoint: once it finishes, its result is saved. If the console crashes, you reload at the last checkpoint instead of level 1. Steps that fail get retried automatically, like respawning.
Sleeping or waiting for a human is the pause menu. The game isn't running, it's not using electricity, and it picks up exactly where it was when someone presses Start.
A queue is a stack of job tickets many workers can grab at once. A sandbox is a padded room with no windows where you let a stranger (AI-generated code) run around without touching your stuff.
3Vercel Workflow: steps, retries, sleep, and approvals
The workflow package (4.x stable as of Sept 2026; 5.x in beta) adds two directives. A function marked 'use workflow' is the orchestrator. Functions marked 'use step' do the real work, with full Node.js access and automatic retries.
Terminal
npm i workflownext.config.ts
import { withWorkflow } from 'workflow/next';
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// ...your existing config
};
export default withWorkflow(nextConfig);Using a proxy.ts (the Next.js 16 name for middleware)? Exclude Workflow's internal paths from its matcher, or runs will fail in confusing ways:
proxy.ts (excerpt)
// proxy.ts: if you have one, keep it away from Workflow's internal routes
export const config = {
matcher: [
{
source: "/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)",
},
],
};A newsletter agent that waits for your OK
First, a typed hook: a named door the workflow can wait behind. The zod schema validates whatever arrives.
workflows/hooks.ts
import { defineHook } from 'workflow';
import { z } from 'zod';
export const approvalHook = defineHook({
schema: z.object({
approved: z.boolean(),
note: z.string().optional(),
}),
});Then the workflow. Each await on a step is a checkpoint. A normal thrown error means "try again"; a FatalError means "stop, retrying won't help."
workflows/newsletter.ts
import { FatalError, sleep } from 'workflow';
import { generateText } from 'ai';
import { approvalHook } from './hooks';
export async function newsletterWorkflow(issueId: string) {
'use workflow';
const sources = await gatherSources(issueId);
const draft = await writeDraft(sources);
// Pause until a human clicks Approve / Reject. Could be minutes or days.
const hook = approvalHook.create({ token: `newsletter:${issueId}` });
const { approved, note } = await hook;
if (!approved) return { status: 'rejected', note };
await sleep('1d'); // send tomorrow morning; no function is running meanwhile
await sendNewsletter(issueId, draft);
return { status: 'sent' };
}
async function gatherSources(issueId: string) {
'use step';
const res = await fetch(`https://example.com/api/issues/${issueId}/links`);
if (res.status === 404) throw new FatalError('Issue does not exist'); // don't retry
if (!res.ok) throw new Error(`Upstream ${res.status}`); // retried automatically
return (await res.json()) as string[];
}
async function writeDraft(sources: string[]) {
'use step';
const { text } = await generateText({
model: 'anthropic/claude-sonnet-5',
prompt: `Write a short newsletter from these links:\n${sources.join('\n')}`,
});
return text;
}
async function sendNewsletter(issueId: string, draft: string) {
'use step';
console.log('sending', issueId, draft.length); // call your email provider here
}Start it from any route handler or Server Action Server Actions Next.js feature that lets you run server-side code directly from React components. Mark a function with 'use server' and call it from forms or buttons. "Like a direct line to the kitchen from your table. No waiter needed — press a button and the order goes straight to the chef."
app/api/newsletter/route.ts
import { start } from 'workflow/api';
import { newsletterWorkflow } from '@/workflows/newsletter';
export async function POST(req: Request) {
const { issueId } = await req.json();
const run = await start(newsletterWorkflow, [issueId]); // returns immediately
return Response.json({ runId: run.runId });
}And the approve button calls this. It resumes the paused run, even if that's three days and two deploys later:
app/api/approve/route.ts
import { approvalHook } from '@/workflows/hooks';
export async function POST(req: Request) {
// Check the caller is an admin before this line (auth omitted for brevity).
const { issueId, approved, note } = await req.json();
const hook = await approvalHook.resume(`newsletter:${issueId}`, { approved, note });
if (!hook) return Response.json({ error: 'No run is waiting' }, { status: 404 });
return Response.json({ ok: true, runId: hook.runId });
}Terminal
# Watch runs, steps, retries, and sleeps locally
npx workflow web
# or in the terminal
npx workflow inspect runsRules of thumb: put anything with side effects (API calls, database writes, model calls) in a step. Keep the workflow function itself to plain orchestration logic. Hooks and sleep are called from the workflow, not from inside a step.
Want the whole tool loop durable? The Workflow SDK also ships an experimental DurableAgent (package @workflow/ai) where each tool call can be its own step.
4Queues: fan out lots of small jobs (beta)
A user uploads 500 PDFs to embed for RAG RAG (Retrieval Augmented Generation) A technique where AI retrieves relevant documents before generating a response. Helps AI answer questions about your specific data. "Like giving the AI a search engine for your documents before it answers."
Terminal
npm i @vercel/queueapp/api/import/route.ts (producer)
import { send } from '@vercel/queue';
export async function POST(req: Request) {
const { docIds }: { docIds: string[] } = await req.json();
// One message per document: 500 docs = 500 small, independently retried jobs.
await Promise.all(
docIds.map((docId) => send('embed-doc', { docId }, { idempotencyKey: `embed:${docId}` })),
);
return Response.json({ queued: docIds.length });
}app/api/queues/embed-doc/route.ts (consumer)
import { handleCallback } from '@vercel/queue';
export const POST = handleCallback<{ docId: string }>(async (message, metadata) => {
console.log(`attempt ${metadata.deliveryCount} for ${message.docId}`);
await embedDocument(message.docId); // throw = message is retried later
});vercel.json
{
"functions": {
"app/api/queues/embed-doc/route.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "embed-doc" }]
}
}
}Delivery is at least once: a message can occasionally arrive twice. Make the consumer idempotent (for example, "upsert embedding for docId" instead of "insert"), and use idempotencyKey when sending.
5Sandbox: run code you didn't write
If your agent writes code and then runs it (a data-analysis bot, an app builder, a "fix my script" tool), never run it in your own function. That code can read your env vars and call anything. Vercel Sandbox (generally available) gives you a throwaway Firecracker microVM per job.
Terminal
npm i @vercel/sandbox
vercel link
vercel env pull # OIDC token the SDK uses to authenticatelib/run-untrusted.ts
import { Sandbox } from '@vercel/sandbox';
export async function runUntrustedJs(code: string) {
const sandbox = await Sandbox.create({
timeout: 60_000, // the whole VM dies after 60s no matter what
networkPolicy: 'deny-all', // generated code can't phone home
});
try {
await sandbox.writeFiles([{ path: 'main.js', content: code }]);
const result = await sandbox.runCommand('node', ['main.js'], { timeoutMs: 10_000 });
return { exitCode: result.exitCode, output: await result.output('both') };
} finally {
await sandbox.stop();
}
}networkPolicy: 'deny-all' (or an allow-list of domains) so a prompt-injected script can't upload data somewhere. And don't pass your real secrets in env. More in Prompt Injection Security.6Common traps
Side effects outside steps
Code in the workflow body can be replayed. An email sent there could go out twice. Put it in a step.
Non-idempotent steps
Steps retry. “Charge card” that fails after charging will charge again. Pass an idempotency key to Stripe and friends.
Retrying the unretryable
A 404 or a bad email address will never succeed. Throw FatalError so you don't burn retries and tokens.
Unauthenticated resume routes
Anyone who can POST to your approve route can approve. Check the session and that the user may approve this run.
Guessable hook tokens
Tokens like newsletter:42 are fine behind auth. If the link goes out by email, use a random token instead.
Reaching for durability too early
A 10-second chat reply doesn't need a workflow. Extra moving parts are extra things to debug.
7Which tool for which job
| Situation | Reach for |
|---|---|
| Chat reply or a few tool calls that finish in well under a minute | A plain route handler with streamText and a sensible maxDuration |
| Small follow-up work after responding (log usage, send analytics) | waitUntil on Fluid compute, still inside the same function |
| Multi-step agent, long runs, must survive crashes and deploys | Vercel Workflow ('use workflow' + 'use step') |
| Waiting on a human, a webhook, or a date | Workflow hooks and sleep |
| Hundreds of independent jobs, spikes of traffic | Vercel Queues (beta) |
| Running AI-generated or user-uploaded code | Vercel Sandbox, network locked down |
Start with the plain route handler from AI SDK + Gateway. Move to a workflow the first time you see a timeout in logs or need a "wait for approval" step.
Docs: Workflow · Queues · Sandbox · Function limits
Long-running agents run up long bills
Now that your agent can run for hours, make sure it doesn't spend like it, and that nobody can talk it into misbehaving.