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
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 tutorial2. 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
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.
| Topic | Vercel Workflow | Temporal | Inngest |
|---|---|---|---|
| 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. |
| Hosting | Managed 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. |
| Retries | Three 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 human | defineHook, 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 dev | Zero 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 model | Events ($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 know | 50 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 expiresHow I would choose
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
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
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/approve5. 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
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
- Vercel — Workflows overview (event log, replay, Fluid compute, Queues)
- Vercel — Workflow pricing and limits (events, data written, payload and run limits)
- Vercel — Functions limits (max duration with Fluid compute)
- Workflow SDK — Next.js getting started (withWorkflow, start, local inspector)
- Workflow SDK — Errors & retrying (retries, FatalError, RetryableError)
- Workflow SDK — Hooks (defineHook, resume, timeouts with sleep)
- Workflow SDK — Idempotency (stepId as idempotency key)
- Temporal — Signals in the TypeScript SDK (defineSignal, setHandler, condition)
- Temporal — Retry policies (activity defaults)
- Temporal — Payload and gRPC message size limits
- Temporal Cloud — Pricing (Actions, storage, plans)
- Inngest — Usage limits (step output, run state, run length)
- Inngest — Pricing (executions and plans)
- Inngest — step.waitForEvent



