AI DevelopmentTutorial

Durable Agents in 2026: Keep Your AI Agent Alive Through Timeouts, Deploys and Slow Humans

October 14, 2026
13 min read
Durable agent workflow guide comparing Vercel Workflow, Temporal and Inngest
Share:

Here is the agent shape that breaks naive serverless setups: it drafts a refund with an LLM, pings a manager, waits for a yes that may take two days, then moves money. One request cannot hold that. A 300-second function dies mid-wait, a redeploy wipes the in-memory loop, and a careless retry charges the customer twice.

This guide covers the fix, durable execution, and compares the three options I would shortlist for a TypeScript team in 2026: Vercel Workflow, Temporal and Inngest. Then you build one agent end to end: an LLM step, a tool step with an idempotency key, a human approval and a resume. Every API and limit was checked against the vendors’ docs on 2026-10-14. I have not load-tested these platforms, so read the example as a pattern, not production code.

1. The Problem: An Agent Is a Process, Serverless Gives You a Request

A request-shaped runtime assumes work finishes quickly. On Vercel with Fluid compute a function defaults to 300 seconds; Pro and Enterprise can raise it to 800 seconds, and a 1,800-second extended maximum is still in beta. Agent work ignores those numbers: model calls fail and need retries, tools are slow, and a human approval can take days. And while the run waits, you will deploy new code.

The Most Common Mistake

Keeping the agent’s position in memory: a while loop with setTimeout or polling inside a route handler, or a database flag plus a cron job that re-reads half-written state. It demos fine. The first timeout, crash or redeploy loses where the run was, and the retry you bolt on repeats a side effect that already happened.

Durable execution moves ’where am I’ out of process memory and into a persisted log. You still write an ordinary async function, but every completed step is recorded; if the process dies, the run is rebuilt from the log and continues where it stopped. Vercel’s docs say its runs survive deployments and crashes through deterministic replays, the same idea Temporal and Inngest implement in their own ways.

Not the same as LangGraph checkpoints

My LangGraph tutorial covers checkpointed conversation state and interrupts inside one agent process. Durable execution sits one layer below: it makes the process itself allowed to disappear. They solve neighbouring problems and you can use either without the other, so I will not repeat checkpointing here.

Read the LangGraph tutorial

2. Minimum Concepts Before Touching Code

Six ideas cover the whole model. The names change per product, the shape does not.

📜

Event history

the source of truth

Every state transition (step scheduled, completed, sleep, hook received) is appended to a log, and the run’s state is derived from it. Vercel calls it the event log, Temporal the Event History.

🔁

Deterministic replay

how a run resumes

After a crash or deploy the engine re-executes your workflow function from the top and feeds recorded results back instead of redoing the work. That only works if the code makes the same decisions given the same history.

🧱

Steps / activities

where side effects live

Anything external or non-deterministic (LLM call, HTTP request, database write) goes in a step on Vercel and Inngest, an activity on Temporal. Its result is recorded and reused on replay.

♻️

Retries

per step, not per run

A failing step is retried without re-running the steps that already succeeded. Defaults differ: three retries on Vercel, four on Inngest, unlimited with backoff for Temporal activities.

🙋

Hooks / signals / events

waiting for a human

The run suspends and uses no compute until a message arrives: a hook on Vercel, a signal on Temporal, step.waitForEvent on Inngest.

⏳

Durable timers

sleep for days

A sleep is recorded in the log, so ’two days’ survives restarts. Racing a timer against an approval gives every human wait a defined timeout.

The rule to keep

Decide in the workflow, act in steps. The workflow function is a plan that gets replayed, so it must be deterministic. Steps are where the world is touched: each runs, gets recorded, and its recorded result is reused on replay.

3. Vercel Workflow vs Temporal vs Inngest: What Actually Differs

All three give you replay, retries, timers and external waits. They differ in where your code runs, how strict the programming model is and how you pay. The facts below come from each vendor’s docs on 2026-10-14; limits and prices move, so re-check them before you budget.

TopicVercel WorkflowTemporalInngest
Programming model’use workflow’ and ’use step’ directives on plain async functions (TypeScript or Python). Open-source Workflow SDK, npm package workflow 5.2.2.Workflow functions in a deterministic sandbox call Activities through proxyActivities. TypeScript SDK 1.24.0 on npm.Functions with step.run, step.sleep and step.waitForEvent. The handler is re-invoked and finished steps are memoized, with no custom runtime rules. npm package inngest 4.24.0.
HostingManaged on Vercel: Functions run the code, Queues dispatch it, managed persistence stores state. Local World in dev; self-hosting through community or Postgres Worlds.A Temporal Service (Cloud or self-hosted) plus Worker processes that you develop and operate on your own hosts.Inngest calls an HTTP endpoint you serve, which works on serverless hosts; Connect is the persistent-worker alternative. Source is on GitHub for self-hosting.
RetriesThree retries by default (four attempts), maxRetries per step. FatalError skips retries; RetryableError sets retryAfter.Activities: unlimited attempts, 1 s initial interval, x2 backoff, 100 s cap. Workflows do not retry by default.Four retries after the first attempt, counted per step. NonRetriableError stops; RetryAfterError controls timing.
Waiting days for a humandefineHook, create({ token }), await it; resume from an API route. Race it with sleep for a timeout. The docs list no limit on sleep duration.defineSignal and setHandler, then condition(fn, timeout); the client calls handle.signal. Timers can sleep for months.step.waitForEvent with a timeout returns the event or null. Bounded by the plan’s run length: 30 days Free, 90 Pro, 366 Business.
Local devZero config. Data in .workflow-data/; inspect with npx workflow web or npx workflow inspect runs.temporal server start-dev (UI on :8233) and a Worker you keep running. Node.js 20 or later.npx inngest-cli@latest dev (UI on :8288) and INNGEST_DEV=1 in your app.
Pricing modelEvents ($0.02 per 1K), data written ($0.50/GB) and data retained ($0.50/GB-month), plus Functions and Queues. Hobby includes 50,000 events a month.Cloud bills Actions ($50 per million at the entry tier) and storage, plus a plan fee (Developer: 10% of usage). Or self-host and run it yourself.Billed per execution, and steps count: five step.run calls make six executions. Free: 50k a month. Pro from $99 a month with 1M included.
Limits to know50 MB per payload; 25,000 events and 10,000 steps per run; a replay attempt over 240 s can abort the run. Run data kept 1 day Hobby, 7 Pro.2 MB per payload and 4 MB per gRPC request; history capped at 51,200 events or 50 MB.4 MiB per step return, 32 MiB run state, 1,000 steps per function.

The same approval wait in three dialects

// Vercel Workflow - hook + timer race
const hook = approvalHook.create({ token: "refund:" + orderId });
const approval = hook.then((p) => p);
const decision = await Promise.race([approval, sleep("2 days").then(() => "timeout" as const)]);
// resume: await approvalHook.resume("refund:" + orderId, { approved: true });

// Temporal - signal + condition with timeout
export const approve = defineSignal<[{ approved: boolean }]>("approve");
let decision: { approved: boolean } | undefined;
setHandler(approve, (d) => { decision = d; });
const arrived = await condition(() => decision !== undefined, "2 days"); // false on timeout
// client: await handle.signal(approve, { approved: true });

// Inngest - wait for an event
const review = await step.waitForEvent("wait-for-approval", {
  event: "refund/approved",
  timeout: "2d",
  match: "data.orderId",
}); // null when the timeout expires

How I would choose

Next.js on Vercel and no appetite for infrastructure: Vercel Workflow, accepting that the managed engine is tied to Vercel (a Postgres World exists for self-hosting). Strict workflow versioning, very long histories and polyglot teams, and you are happy to run workers or pay for Cloud: Temporal. Event-driven product where an HTTP endpoint on your serverless host is all you want to operate: Inngest. This is my reading of the docs, not a benchmark.

4. Tutorial: An Approval-Gated Agent on Vercel Workflow

I picked Vercel Workflow for the worked example because its Next.js quickstart is the shortest of the three, it runs locally with no extra service, and a Next.js site on Vercel already hosts the steps. Versions checked on npm on 2026-10-14: workflow 5.2.2 (stable line since 5.0.0 on 2026-09-30), ai 7.0.140 and zod 4.6.7. The agent proposes a refund with an LLM, waits for a human, then calls a payment tool exactly once.

Step 1 — Install and wrap the Next.js config

Install the packages and wrap your config with withWorkflow. That is the one required config change; the directives do the rest.

Terminal + next.config.ts

npm i workflow ai zod

// next.config.ts
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";

const nextConfig: NextConfig = {};

export default withWorkflow(nextConfig);

Step 2 — The LLM step, the tool step, the hook and the workflow

Five pieces in one file. proposeRefund calls the model and returns a typed object. requestReview pings the reviewer. approvalHook is the typed wait. issueRefund is the only step that moves money, so it carries an idempotency key. refundAgent only orchestrates. The model string routes through Vercel AI Gateway, so configure its credentials (or pass a provider instance) before the first run.

TypeScript — workflows/refund-agent.ts

// workflows/refund-agent.ts
import { defineHook, sleep, getStepMetadata, FatalError } from "workflow";
import { generateText, Output } from "ai";
import { z } from "zod";

type Ticket = { orderId: string; message: string };
type Plan = { amountCents: number; reason: string };

// The human decision, validated before the run resumes
export const approvalHook = defineHook({
  schema: z.object({ approved: z.boolean(), comment: z.string().optional() }),
});

// LLM step: proposes, never executes
async function proposeRefund(ticket: Ticket): Promise<Plan> {
  "use step";
  const { output } = await generateText({
    model: "anthropic/claude-sonnet-5.5", // any AI Gateway "provider/model" string
    instructions: "You triage refund requests. Propose a refund amount in cents.",
    prompt: ticket.message,
    output: Output.object({
      schema: z.object({ amountCents: z.number(), reason: z.string() }),
    }),
  });
  return output;
}

// Notification step: a retry may ping twice - fine for a ping, not for money
async function requestReview(ticket: Ticket, plan: Plan) {
  "use step";
  await fetch(process.env.REVIEW_WEBHOOK_URL!, { // the secret stays inside the step
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      text: "Refund " + plan.amountCents + " cents for order " + ticket.orderId + ": " + plan.reason,
    }),
  });
}

// Tool step: the only place money moves
async function issueRefund(orderId: string, amountCents: number) {
  "use step";
  const { stepId } = getStepMetadata();
  const res = await fetch("https://payments.example.com/v1/refunds", { // placeholder provider
    method: "POST",
    headers: {
      authorization: "Bearer " + process.env.PAYMENTS_API_KEY,
      "content-type": "application/json",
      "idempotency-key": "refund:" + stepId, // stable across retries of this step
    },
    body: JSON.stringify({ orderId, amountCents }),
  });
  if (res.status === 429 || res.status >= 500) throw new Error("Payments API " + res.status); // retried
  if (!res.ok) throw new FatalError("Refund rejected: " + res.status); // not retried
  return (await res.json()) as { refundId: string };
}

// The workflow: deterministic orchestration only
export async function refundAgent(ticket: Ticket) {
  "use workflow";

  const plan = await proposeRefund(ticket);
  if (plan.amountCents > 50_000) return { status: "needs-manager", plan };

  const hook = approvalHook.create({ token: "refund:" + ticket.orderId }); // BEFORE notifying
  await requestReview(ticket, plan);

  const approval = hook.then((payload) => payload);
  const decision = await Promise.race([
    approval,
    sleep("2 days").then(() => "timeout" as const),
  ]);

  if (decision === "timeout" || !decision.approved) {
    return { status: "closed", plan, decision };
  }

  const receipt = await issueRefund(ticket.orderId, plan.amountCents);
  return { status: "refunded", receipt };
}

Notice the order: the hook is created before the reviewer is notified, so a fast yes cannot arrive before anyone is listening; and the wait is raced against a two-day timer, so silence has a defined outcome. The 50,000-cent guard lives in workflow code on purpose: the model proposes, deterministic code disposes.

What happens when the approval arrives

The engine re-runs refundAgent from the top. proposeRefund and requestReview return their recorded results instantly (no second LLM call, no second ping), the hook resolves with the reviewer’s payload, and only issueRefund really executes. Wait a day or deploy in between: by default a run stays on the deployment that started it, so the paused run resumes on the code it began with.

Step 3 — Start the run and resume it from route handlers

start() enqueues the run and returns immediately. The approve route resumes the hook by its token. Tokens are not credentials: the Zod schema validates the payload, not the sender, so authenticate the caller before calling resume().

TypeScript — two route handlers

// app/api/refund/route.ts - start a run
import { start } from "workflow/api";
import { refundAgent } from "@/workflows/refund-agent";

export async function POST(req: Request) {
  const ticket = await req.json();
  const run = await start(refundAgent, [ticket]);
  return Response.json({ runId: run.runId });
}

// app/api/refund/approve/route.ts - resume it
import { approvalHook } from "@/workflows/refund-agent";
import { getReviewer } from "@/lib/auth"; // your own auth, not part of the SDK

export async function POST(req: Request) {
  const reviewer = await getReviewer(req);
  if (!reviewer?.canApproveRefunds) return new Response("Forbidden", { status: 403 });

  const { orderId, approved, comment } = await req.json();
  await approvalHook.resume("refund:" + orderId, { approved, comment });
  return Response.json({ ok: true });
}

Tip: tokens you can rebuild

A token derived from a business key (refund:A-1042) lets the approve route find the run without storing a run ID, and it doubles as a duplicate guard: if an active run already owns that token, the SDK surfaces a conflict (see hook.getConflict()) instead of quietly starting a second refund. If your app has proxy.ts or middleware, exclude .well-known/workflow/ from its matcher.

Step 4 — Run it locally and read the timeline

Locally the SDK uses its Local World with zero configuration. Start a run, open the inspector, then approve. On Vercel the same runs appear in the dashboard under Observability, then Workflows. One deployment gotcha from the docs: ’Enable access to System Environment Variables’ must be on, or the deployment falls back to the Local World and fails on the read-only filesystem.

Terminal — run, inspect, approve

npm run dev

# start a run
curl -X POST --json '{"orderId":"A-1042","message":"Charged twice, please refund the duplicate."}' http://localhost:3000/api/refund

# watch it: web UI or terminal
npx workflow web
npx workflow inspect runs

# the reviewer says yes (your auth must let this through)
curl -X POST --json '{"orderId":"A-1042","approved":true}' http://localhost:3000/api/refund/approve

5. Common Mistakes (Durable Code Needs Them Spelled Out)

Durable execution removes timeouts from your life and adds a new class of bug: code that is correct the first time and wrong the second. These five are the ones every vendor’s docs circle around.

💳

Side effects without idempotency

the double-charge bug

Steps retry, and a retry can follow an attempt that already did the work but lost the response. Pass a key the provider dedupes on: stepId from getStepMetadata() on Vercel (stable across retries), workflow run ID plus activity ID on Temporal. On Inngest make the call idempotent yourself; their docs use a refund step as the example. Payments and emails first.

🎲

Non-deterministic workflow code

the replay breaker

Workflow code is re-executed, so it must decide the same way every time. Keep clocks, randomness, env reads, database and HTTP calls in steps. The SDKs patch some of it (Vercel fixes Math.random and Date; Temporal replaces them and offers uuid4()), but on Inngest anything outside a step can run more than once. Editing workflow logic under in-flight runs is the slow-burn version of the same bug.

📦

Payload size

everything lands in the log

Step inputs and outputs are persisted, and limits vary widely: 50 MB on Vercel, 2 MB per payload and 4 MB per gRPC request on Temporal, 4 MiB per step return on Inngest. Pass references (a blob URL, a row ID), not documents or whole transcripts. Vercel also notes slower replay past 2,000 events, so batch work per step or split into child workflows.

🔑

Secrets

the history is readable

Arguments and return values are recorded and visible to anyone allowed to read run data (on Vercel: team owners or the Workflow Run Data Viewer permission). Read API keys from env inside steps, pass IDs instead of tokens, generate unguessable tokens in a step (seeded randomness is not secret) and authenticate whatever endpoint resumes a hook.

🔍

Observability and replay debugging

the log is your debugger

Open the run, find the failed step, read its input and attempts. Use the dashboard or npx workflow web for Vercel Workflow, the Temporal Web UI, and the Inngest dev server. Log from steps, name them clearly and keep payloads small so the timeline stays readable.

✅ Do this

  • • Decide in the workflow, act in steps
  • • Derive idempotency keys from stepId or a stable business key
  • • Create the hook or register the wait before notifying the human
  • • Authenticate the endpoint that resumes a run
  • • Pass IDs and references, and stay far below payload limits
  • • Race every human wait against a timer and handle the timeout branch

❌ Not this

  • • Calling fetch or reading Date, env or the database in workflow code
  • • Retrying money-moving calls without an idempotency key
  • • Putting API keys or full documents in step arguments or returns
  • • setTimeout, polling, or cron plus a status flag for multi-day waits
  • • Reordering steps of an in-flight workflow with no versioning plan
  • • Treating a hook token as proof that the caller may approve

Golden rule

If a step can run twice, assume it will. Make ’twice’ harmless (an idempotency key, a deduplicating API, a stored result) and the rest of durable execution is plumbing.

Conclusion

Long-running agents fail for boring reasons: a time limit, a restart, a retry that repeats a payment. Durable execution answers all three with one trick, record every step and replay to resume, and the three products here package it differently: directives on Vercel, a deterministic workflow plus workers on Temporal, memoized steps over HTTP on Inngest.

Pick one agent flow you currently babysit with a cron job and a status column. Move the model call and the side effect into steps, give the money-moving one an idempotency key, replace the polling with a hook or signal and race it against a timer. That migration should delete more glue code than it adds, and it is the foundation for every human-in-the-loop agent you build next.

Tutorial Cheat Sheet

Vercel Workflow

  • • ’use workflow’ + ’use step’
  • • defineHook + sleep race
  • • stepId as idempotency key

Temporal

  • • Workflow + Activities + Worker
  • • defineSignal + condition(fn, t)
  • • Mind the 2 MB payload cap

Inngest

  • • step.run + step.waitForEvent
  • • Four retries per step by default
  • • Every step counts as an execution

Sources

Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego →