Este es el tipo de agente que rompe los setups serverless ingenuos: redacta un reembolso con un LLM, le avisa a un manager, espera un sí que puede tardar dos días y recién ahí mueve plata. Un solo request no aguanta eso. Una función de 300 segundos muere en plena espera, un redeploy borra el loop en memoria y un retry descuidado le cobra dos veces al cliente.
Esta guía cubre el arreglo, la ejecución durable, y compara las tres opciones que preseleccionaría un equipo TypeScript en 2026: Vercel Workflow, Temporal e Inngest. Después armás un agente de punta a punta: un paso con LLM, un paso de herramienta con clave de idempotencia, una aprobación humana y la reanudación. Cada API y cada límite lo verifiqué contra las docs de los proveedores el 2026-10-14. No hice pruebas de carga en estas plataformas, así que leé el ejemplo como un patrón, no como código de producción.
1. El Problema: Un Agente Es un Proceso, Serverless Te Da un Request
Un runtime pensado para requests asume que el trabajo termina rápido. En Vercel con Fluid compute una función dura 300 segundos por defecto; Pro y Enterprise pueden subirla a 800 segundos, y un máximo extendido de 1.800 segundos sigue en beta. El trabajo de un agente ignora esos números: las llamadas al modelo fallan y piden reintentos, las herramientas son lentas y una aprobación humana puede tardar días. Y mientras el run espera, vas a deployar código nuevo.
El Error Más Común
La ejecución durable saca el ’dónde estoy’ de la memoria del proceso y lo mete en un log persistido. Seguís escribiendo una función async común, pero cada paso completado queda registrado; si el proceso muere, el run se reconstruye desde el log y sigue donde se había detenido. Las docs de Vercel dicen que sus runs sobreviven a deploys y crashes mediante replays determinísticos, la misma idea que Temporal e Inngest implementan a su manera.
No es lo mismo que los checkpoints de LangGraph
Mi tutorial de LangGraph cubre el estado de conversación con checkpointer y los interrupts dentro de un solo proceso de agente. La ejecución durable está una capa más abajo: hace que el proceso mismo pueda desaparecer. Resuelven problemas vecinos y podés usar cualquiera sin el otro, así que acá no repito el checkpointing.
Leer el tutorial de LangGraph2. Conceptos Mínimos Antes de Tocar Código
Seis ideas cubren todo el modelo. Los nombres cambian según el producto, la forma no.
Historial de eventos
la fuente de verdad
Cada transición de estado (paso agendado, completado, sleep, hook recibido) se agrega a un log, y el estado del run se deriva de ahí. Vercel lo llama event log, Temporal Event History.
Replay determinístico
cómo se reanuda un run
Tras un crash o deploy el motor vuelve a ejecutar tu función workflow desde el principio y le devuelve los resultados registrados en vez de rehacer el trabajo. Solo funciona si el código toma las mismas decisiones con el mismo historial.
Steps / activities
donde viven los efectos
Todo lo externo o no determinístico (llamada al LLM, request HTTP, escritura en la base) va en un step en Vercel e Inngest, en una activity en Temporal. Su resultado queda registrado y se reutiliza en el replay.
Reintentos
por paso, no por run
Un paso que falla se reintenta sin volver a correr los pasos que ya salieron bien. Los defaults difieren: tres reintentos en Vercel, cuatro en Inngest, ilimitados con backoff en las activities de Temporal.
Hooks / signals / events
esperar a un humano
El run se suspende y no usa cómputo hasta que llega un mensaje: un hook en Vercel, una signal en Temporal, step.waitForEvent en Inngest.
Timers durables
dormir por días
Un sleep queda registrado en el log, así que ’dos días’ sobrevive a reinicios. Competir un timer contra una aprobación le da a cada espera humana un timeout definido.
La regla a conservar
3. Vercel Workflow vs Temporal vs Inngest: Qué Cambia de Verdad
Los tres te dan replay, reintentos, timers y esperas externas. Se diferencian en dónde corre tu código, qué tan estricto es el modelo de programación y cómo pagás. Los datos de abajo salen de las docs de cada proveedor al 2026-10-14; los límites y precios se mueven, así que revisalos de nuevo antes de presupuestar.
| Tema | Vercel Workflow | Temporal | Inngest |
|---|---|---|---|
| Modelo de programación | Directivas ’use workflow’ y ’use step’ sobre funciones async comunes (TypeScript o Python). Workflow SDK open source, paquete npm workflow 5.2.2. | Funciones workflow en un sandbox determinístico que llaman Activities con proxyActivities. SDK de TypeScript 1.24.0 en npm. | Funciones con step.run, step.sleep y step.waitForEvent. El handler se re-invoca y los pasos terminados se memoizan, sin reglas de runtime propias. Paquete npm inngest 4.24.0. |
| Hosting | Gestionado en Vercel: Functions ejecutan el código, Queues lo despachan y la persistencia gestionada guarda el estado. Local World en dev; self-hosting con Worlds de la comunidad o de Postgres. | Un Temporal Service (Cloud o self-hosted) más procesos Worker que desarrollás y operás en tus propios hosts. | Inngest llama a un endpoint HTTP que servís vos, lo que funciona en hosts serverless; Connect es la alternativa con workers persistentes. El código está en GitHub para self-hosting. |
| Reintentos | Tres reintentos por defecto (cuatro intentos), maxRetries por paso. FatalError saltea los reintentos; RetryableError define retryAfter. | Activities: intentos ilimitados, 1 s de intervalo inicial, backoff x2, tope de 100 s. Los workflows no reintentan por defecto. | Cuatro reintentos tras el primer intento, contados por paso. NonRetriableError corta; RetryAfterError controla el momento. |
| Esperar días a un humano | defineHook, create({ token }), await; se reanuda desde una API route. Competilo con sleep para el timeout. Las docs no listan un límite de duración para sleep. | defineSignal y setHandler, luego condition(fn, timeout); el cliente llama handle.signal. Los timers pueden dormir meses. | step.waitForEvent con timeout devuelve el evento o null. Limitado por la duración de run del plan: 30 días Free, 90 Pro, 366 Business. |
| Desarrollo local | Cero configuración. Datos en .workflow-data/; inspeccioná con npx workflow web o npx workflow inspect runs. | temporal server start-dev (UI en :8233) y un Worker que dejás corriendo. Node.js 20 o superior. | npx inngest-cli@latest dev (UI en :8288) e INNGEST_DEV=1 en tu app. |
| Modelo de precios | Events ($0,02 cada 1K), datos escritos ($0,50/GB) y datos retenidos ($0,50/GB-mes), más Functions y Queues. Hobby incluye 50.000 events por mes. | Cloud cobra Actions ($50 por millón en el tramo de entrada) y almacenamiento, más un fee de plan (Developer: 10% del uso). O lo alojás y operás vos. | Se cobra por ejecución y los steps cuentan: cinco step.run suman seis ejecuciones. Free: 50k por mes. Pro desde $99 por mes con 1M incluido. |
| Límites a conocer | 50 MB por payload; 25.000 events y 10.000 steps por run; un intento de replay de más de 240 s puede abortar el run. Datos del run guardados 1 día en Hobby, 7 en Pro. | 2 MB por payload y 4 MB por request gRPC; historial con tope de 51.200 eventos o 50 MB. | 4 MiB por retorno de step, 32 MiB de estado del run, 1.000 steps por función. |
La misma espera de aprobación en tres dialectos
// 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 expiresCómo elegiría yo
4. Tutorial: Un Agente con Aprobación Humana en Vercel Workflow
Elegí Vercel Workflow para el ejemplo porque su quickstart de Next.js es el más corto de los tres, corre en local sin servicios extra y un sitio Next.js en Vercel ya aloja los steps. Versiones verificadas en npm el 2026-10-14: workflow 5.2.2 (línea estable desde 5.0.0, el 2026-09-30), ai 7.0.140 y zod 4.6.7. El agente propone un reembolso con un LLM, espera a un humano y recién ahí llama a la herramienta de pagos, una sola vez.
Paso 1 — Instalá y envolvé la config de Next.js
Instalá los paquetes y envolvé tu config con withWorkflow. Es el único cambio de configuración obligatorio; las directivas hacen el resto.
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);Paso 2 — El paso con LLM, el paso de herramienta, el hook y el workflow
Cinco piezas en un archivo. proposeRefund llama al modelo y devuelve un objeto tipado. requestReview le avisa al revisor. approvalHook es la espera tipada. issueRefund es el único paso que mueve plata, así que lleva una clave de idempotencia. refundAgent solo orquesta. El string del modelo pasa por Vercel AI Gateway, así que configurá sus credenciales (o pasá una instancia de proveedor) antes de la primera corrida.
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 };
}Fijate en el orden: el hook se crea antes de avisar al revisor, así un sí rápido no llega cuando nadie escucha; y la espera compite contra un timer de dos días, así el silencio tiene un desenlace definido. El tope de 50.000 centavos vive en el código del workflow a propósito: el modelo propone, el código determinístico dispone.
Qué pasa cuando llega la aprobación
Paso 3 — Iniciá el run y reanudalo desde route handlers
start() encola el run y vuelve enseguida. La ruta approve reanuda el hook por su token. Los tokens no son credenciales: el schema de Zod valida el payload, no a quien lo manda, así que autenticá al caller antes de llamar a resume().
TypeScript — dos 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 que podés reconstruir
Paso 4 — Corrélo en local y leé la línea de tiempo
En local el SDK usa su Local World sin configuración. Iniciá un run, abrí el inspector y después aprobá. En Vercel los mismos runs aparecen en el dashboard, en Observability y luego Workflows. Una trampa de deploy que mencionan las docs: ’Enable access to System Environment Variables’ tiene que estar activo, o el deployment cae al Local World y falla por el filesystem de solo lectura.
Terminal — correr, inspeccionar, aprobar
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. Errores Comunes (el Código Durable Necesita Que Se Los Digan)
La ejecución durable te saca los timeouts de encima y agrega una clase nueva de bug: código que es correcto la primera vez e incorrecto la segunda. Estos cinco son los que rodean las docs de todos los proveedores.
Efectos sin idempotencia
el bug del doble cobro
Los steps se reintentan, y un reintento puede seguir a un intento que ya hizo el trabajo pero perdió la respuesta. Pasá una clave que el proveedor deduplique: stepId de getStepMetadata() en Vercel (estable entre reintentos), run ID del workflow más activity ID en Temporal. En Inngest hacé idempotente la llamada vos mismo; sus docs usan un paso de reembolso como ejemplo. Primero pagos y emails.
Código de workflow no determinístico
el que rompe el replay
El código del workflow se re-ejecuta, así que tiene que decidir igual cada vez. Dejá relojes, aleatoriedad, lecturas de env, base de datos y HTTP en steps. Los SDKs parchean parte (Vercel fija Math.random y Date; Temporal los reemplaza y ofrece uuid4()), pero en Inngest todo lo que esté fuera de un step puede correr más de una vez. Editar la lógica del workflow bajo runs en curso es la versión de combustión lenta del mismo bug.
Tamaño de payloads
todo termina en el log
Las entradas y salidas de los steps se persisten, y los límites varían mucho: 50 MB en Vercel, 2 MB por payload y 4 MB por request gRPC en Temporal, 4 MiB por retorno de step en Inngest. Pasá referencias (una URL de blob, un ID de fila), no documentos ni transcripciones enteras. Vercel además avisa que el replay se vuelve lento pasados los 2.000 eventos, así que agrupá trabajo por step o dividí en child workflows.
Secretos
el historial se puede leer
Argumentos y valores de retorno quedan registrados y los ve cualquiera con permiso para leer datos de runs (en Vercel: owners del team o el permiso Workflow Run Data Viewer). Leé las API keys del env dentro de los steps, pasá IDs en vez de tokens, generá tokens impredecibles en un step (la aleatoriedad con semilla no es secreta) y autenticá el endpoint que reanuda un hook.
Observabilidad y debugging por replay
el log es tu debugger
Abrí el run, encontrá el paso que falló y leé su input e intentos. Usá el dashboard o npx workflow web en Vercel Workflow, la Web UI de Temporal y el dev server de Inngest. Logueá desde los steps, ponéles nombres claros y mantené los payloads chicos para que la línea de tiempo se lea.
✅ Hacé esto
- • Decidí en el workflow, actuá en los steps
- • Derivá las claves de idempotencia de stepId o de una clave de negocio estable
- • Creá el hook o registrá la espera antes de avisar al humano
- • Autenticá el endpoint que reanuda un run
- • Pasá IDs y referencias, y quedate lejos de los límites de payload
- • Competí cada espera humana contra un timer y manejá la rama de timeout
❌ No esto
- • Llamar a fetch o leer Date, env o la base en código de workflow
- • Reintentar llamadas que mueven plata sin clave de idempotencia
- • Poner API keys o documentos completos en argumentos o retornos de steps
- • setTimeout, polling o cron más un flag de estado para esperas de días
- • Reordenar pasos de un workflow en curso sin plan de versionado
- • Tratar un token de hook como prueba de que el caller puede aprobar
Regla de oro
Conclusión
Los agentes largos fallan por razones aburridas: un límite de tiempo, un reinicio, un reintento que repite un pago. La ejecución durable responde a las tres con un solo truco, registrar cada paso y hacer replay para reanudar, y los tres productos de acá lo empaquetan distinto: directivas en Vercel, un workflow determinístico más workers en Temporal, steps memoizados sobre HTTP en Inngest.
Elegí un flujo de agente que hoy cuidás con un cron y una columna de estado. Mové la llamada al modelo y el efecto secundario a steps, dale al que mueve plata una clave de idempotencia, reemplazá el polling por un hook o signal y competilo contra un timer. Esa migración debería borrar más código pegamento del que agrega, y es la base de cada agente con humano en el loop que construyas después.
Machete del Tutorial
Vercel Workflow
- • ’use workflow’ + ’use step’
- • defineHook + sleep en carrera
- • stepId como clave de idempotencia
Temporal
- • Workflow + Activities + Worker
- • defineSignal + condition(fn, t)
- • Ojo al tope de 2 MB por payload
Inngest
- • step.run + step.waitForEvent
- • Cuatro reintentos por paso por defecto
- • Cada step cuenta como ejecución
Fuentes
- Vercel — Resumen de Workflows (event log, replay, Fluid compute, Queues)
- Vercel — Precios y límites de Workflow (events, datos escritos, límites de payload y run)
- Vercel — Límites de Functions (duración máxima con Fluid compute)
- Workflow SDK — Primeros pasos con Next.js (withWorkflow, start, inspector local)
- Workflow SDK — Errores y reintentos (retries, FatalError, RetryableError)
- Workflow SDK — Hooks (defineHook, resume, timeouts con sleep)
- Workflow SDK — Idempotencia (stepId como clave de idempotencia)
- Temporal — Signals en el SDK de TypeScript (defineSignal, setHandler, condition)
- Temporal — Retry policies (defaults de las activities)
- Temporal — Límites de tamaño de payload y mensaje gRPC
- Temporal Cloud — Precios (Actions, almacenamiento, planes)
- Inngest — Límites de uso (salida de steps, estado del run, duración del run)
- Inngest — Precios (ejecuciones y planes)
- Inngest — step.waitForEvent



