Back to Knowledge
New

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

, queues

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

, and sandboxes

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

function: it spins up for a request and has a time limit. As of September 2026 the default is 300 seconds on every plan; Pro and Enterprise can raise the max to 800 seconds (1,800 in beta). That sounds like plenty until you do the math on an agent.

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 workflow

next.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."

. It returns right away with a run ID; the work continues in the background.

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 runs

Rules 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."

. Doing them in one request is a timeout waiting to happen. Instead, drop one message per document on a topic, and let Vercel invoke a consumer function for each. Vercel Queues is in beta as of September 2026, so expect config names to shift.

Terminal

npm i @vercel/queue

app/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 authenticate

lib/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();
  }
}
The default network policy is full internet access. For AI-generated code, pass 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

SituationReach for
Chat reply or a few tool calls that finish in well under a minuteA 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 deploysVercel Workflow ('use workflow' + 'use step')
Waiting on a human, a webhook, or a dateWorkflow hooks and sleep
Hundreds of independent jobs, spikes of trafficVercel Queues (beta)
Running AI-generated or user-uploaded codeVercel 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.