●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Claude API × Inngest — Durable AI Workflows with Retries, Idempotency, and Human Approval
A production-grade pattern for combining Claude API with Inngest. Build TypeScript-first durable AI workflows that retry safely, stay idempotent, gate dangerous calls behind human approval, and run on Vercel or Cloudflare Workers.
"That overnight AI batch died at 3am again." If you've shipped Claude API to production for any length of time, you know that feeling. I run four AI-focused blog sites that each generate and update articles via Claude API on a schedule, and the first few months were a string of 5xx errors, mid-stream timeouts, and tool calls that double-charged Stripe because retries weren't safe.
The lesson I keep relearning is that production AI workflows need to resume safely from the middle. Wrapping the whole thing in try/catch, building a job table by hand, splitting work across queues — I tried all of these. They each work for about a week and then collapse under their own weight.
This article shows how to combine Inngest, a TypeScript-first durable execution engine, with the Claude API to build production-grade AI workflows. Everything sits inside a single Next.js app, so you don't need to spin up extra infrastructure on Vercel, Cloudflare Workers, or wherever your code already lives.
Almost every Claude agent starts life looking like this:
// The naive version. Works for demos, breaks in production.async function runAgent(userInput: string) { const messages = [{ role: "user", content: userInput }]; while (true) { const res = await anthropic.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, tools, messages, }); messages.push({ role: "assistant", content: res.content }); if (res.stop_reason === "end_turn") return res; if (res.stop_reason === "tool_use") { const toolResult = await runTool(res); // ← side-effecting external API messages.push({ role: "user", content: toolResult }); } }}
A one-shot demo runs fine. Ship it and you start hitting:
Function timeouts. Vercel serverless functions cap at 5 minutes, Cloudflare Workers at ~30 seconds of CPU. A three-turn tool-use loop can blow through that.
Double-billed retries. A timeout, a user retry, and now Stripe has been charged twice. I've done this. It's awful.
Opaque progress. You can't tell from logs which message in the array was the last one Claude actually saw.
No clean way to pause for human approval. The moment a security-conscious teammate asks for an approval gate, the whole loop has to be rewritten.
These aren't bugs you can patch independently. They all stem from the same missing capability: resuming a long-running process safely after a crash. I tried Redis checkpoints, Postgres job tables, custom retry decorators. After enough nights, I realized I was writing a workflow engine, badly.
Inngest in five minutes
Inngest is a durable execution engine designed for TypeScript and JavaScript. It shares the "Durable Execution" mindset of Temporal, but the developer experience collapses into a single Next.js API route.
Four concepts are enough to start:
Function: a handler that runs in response to an event, defined with inngest.createFunction().
Step: a unit inside a Function that says "if you crash, please remember the result so far." step.run() is the workhorse — its return value is persisted, and reruns skip past it.
Event: the trigger. Send one with inngest.send({ name, data }).
Sleep / WaitForEvent: primitives that pause the function. The function literally exits and Inngest wakes it up later.
The crucial intuition: step boundaries are resume points. If a step succeeds, its result is durable; the function can crash arbitrarily and rerun, and that step won't fire its body again.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've ever watched a long-running Claude job die mid-flight and dreaded the rerun, you'll learn how to wrap it in Inngest's step.run so retries skip work already done — in the TypeScript stack you already know.
✦You'll get working code for tool-use agent loops, idempotency keys, retry classification, human-approval gates, and fan-out/fan-in batches — copy-paste ready into a Next.js app.
✦You'll come away with a serverless durable workflow you can deploy to Vercel or Cloudflare Workers without standing up Temporal, Airflow, or your own queue infrastructure.
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
// src/inngest/client.tsimport { Inngest } from "inngest";export const inngest = new Inngest({ id: "claude-lab-agent", // The event key is read from env automatically in production.});
// src/app/api/inngest/route.tsimport { serve } from "inngest/next";import { inngest } from "@/inngest/client";import { runResearchAgent } from "@/inngest/functions/research-agent";export const { GET, POST, PUT } = serve({ client: inngest, functions: [runResearchAgent],});
Run the local dev server and you get a UI showing every function execution, every step, and one-click rerun:
npx inngest-cli dev# Open http://localhost:8288
Building a durable Claude agent step by step
The rest of the article walks through a tool-using research agent and progressively adds retries, idempotency, human approval, and fan-out batches.
Step 1: wrap every Claude call in step.run
The single most important rule: every Claude API call belongs inside step.run(). That alone keeps reruns from re-billing Anthropic.
// src/inngest/functions/research-agent.tsimport Anthropic from "@anthropic-ai/sdk";import { inngest } from "../client";const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });const tools: Anthropic.Tool[] = [ { name: "web_search", description: "Search public sources and return the top 3 snippets.", input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], }, }, { name: "send_summary_email", description: "Send the finished summary to a recipient.", input_schema: { type: "object", properties: { to: { type: "string" }, subject: { type: "string" }, body: { type: "string" }, }, required: ["to", "subject", "body"], }, },];export const runResearchAgent = inngest.createFunction( { id: "run-research-agent", // Cap parallelism so a runaway loop doesn't melt Anthropic's rate limit. concurrency: { limit: 5 }, // Default retry budget for transient Anthropic errors. retries: 4, }, { event: "research/agent.requested" }, async ({ event, step, logger }) => { const { topic, requestedBy, jobId } = event.data as { topic: string; requestedBy: string; jobId: string; }; let messages: Anthropic.MessageParam[] = [ { role: "user", content: `Research and summarize this topic: ${topic}` }, ]; // Hard cap turns to prevent infinite loops. for (let turn = 0; turn < 6; turn++) { // Every Claude call is inside step.run, so reruns reuse the cached result. const response = await step.run(`claude-turn-${turn}`, async () => { return await anthropic.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, tools, messages, }); }); messages.push({ role: "assistant", content: response.content }); if (response.stop_reason === "end_turn") { logger.info("agent finished", { turn, jobId }); return { ok: true, finalMessages: messages }; } if (response.stop_reason === "tool_use") { const toolUses = response.content.filter( (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", ); const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const tu of toolUses) { // Splitting tool calls into their own steps lets one failed tool // resume independently of the others. const result = await step.run(`tool-${turn}-${tu.id}`, async () => { return runTool(tu.name, tu.input, { jobId, requestedBy }); }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, content: JSON.stringify(result), }); } messages.push({ role: "user", content: toolResults }); } } throw new Error(`Agent did not converge in 6 turns (jobId=${jobId})`); },);
The first argument to step.run() must be unique within the function execution. Inside a loop, always include the iteration number — claude-turn-${turn} — otherwise Inngest will see the same step ID twice and assume it can return the cached result, silently skipping a Claude call.
Step 2: give every tool an idempotency key
runTool() is where the side effects live: emails, charges, database writes. Inngest skips re-running successful steps, but tools that themselves call multiple downstream APIs can still produce duplicates if a step fails halfway through.
The fix is a defense-in-depth idempotency key. Using the tool_use_id (or a hash of its arguments) is the simplest approach.
// src/inngest/functions/tools.tsimport { Resend } from "resend";import { db } from "@/db";const resend = new Resend(process.env.RESEND_API_KEY!);export async function runTool( name: string, input: unknown, ctx: { jobId: string; requestedBy: string },): Promise<unknown> { if (name === "web_search") { return webSearch((input as { query: string }).query); } if (name === "send_summary_email") { const args = input as { to: string; subject: string; body: string }; const idempotencyKey = `${ctx.jobId}:email:${hash(args)}`; // Pre-mark the operation as pending. If it already exists, we can short-circuit. const inserted = await db.idempotency.upsert({ where: { key: idempotencyKey }, create: { key: idempotencyKey, status: "pending" }, update: {}, }); if (inserted.status === "completed") { return { skipped: true, reason: "already sent" }; } const result = await resend.emails.send({ from: "agent@example.com", to: args.to, subject: args.subject, text: args.body, }); await db.idempotency.update({ where: { key: idempotencyKey }, data: { status: "completed", externalId: result.id }, }); return { sent: true, id: result.id }; } throw new Error(`unknown tool: ${name}`);}async function webSearch(query: string) { // Search API call (omitted) return [{ title: "...", snippet: "...", url: "..." }];}function hash(obj: unknown) { // Toy hash. Swap for SHA-256 via crypto.subtle in real code. return Buffer.from(JSON.stringify(obj)).toString("base64").slice(0, 24);}
The design choice here is don't put all your trust in Inngest's step boundary. Inngest prevents reruns between steps, but if a step calls out to an external API and then the process dies before recording the result, the step retries and the external API gets hit twice. A database-backed idempotency key is the second line of defense, and in my experience it's not optional in production.
Policy refusal. The model declined the request, surfaced as a refusal stop reason or content.
Retrying all of these four times means a typo in your tool schema causes four wasted calls and a one-minute delay before the user sees the error. Inngest exposes NonRetriableError to short-circuit retries.
import { NonRetriableError } from "inngest";const response = await step.run(`claude-turn-${turn}`, async () => { try { return await anthropic.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, tools, messages, }); } catch (e) { if (e instanceof Anthropic.APIError) { // 4xx (except 429) means the request itself is bad — don't waste retries. if (e.status && e.status >= 400 && e.status < 500 && e.status !== 429) { throw new NonRetriableError( `claude rejected request: ${e.status} ${e.message}`, { cause: e }, ); } } throw e; // Anything else, let Inngest's exponential backoff handle it. }});
Step 4: pause for human approval with waitForEvent
"Approve before sending email" or "double-check large charges" are the kind of requirements that turn a clean async function into spaghetti. step.waitForEvent() lets the function pause and wait for an external event — without consuming serverless function time.
// Pause for human approval before charging large amounts.if (toolName === "charge_customer" && (input as { amount: number }).amount > 50000) { await step.run("request-approval", async () => { return notifyApprovers({ jobId, amount: (input as { amount: number }).amount, requestedBy, }); }); // Wait up to 24 hours for the approval event. const approval = await step.waitForEvent("wait-for-approval", { event: "agent/approval.received", timeout: "24h", if: `event.data.jobId == "${jobId}"`, }); if (!approval) { throw new NonRetriableError("Approval timed out after 24h"); } if (approval.data.decision !== "approved") { return { skipped: true, reason: "rejected by human" }; } // Proceed with the actual charge.}
On the approval UI side, clicking "Approve" simply calls inngest.send({ name: "agent/approval.received", data: { jobId, decision: "approved", reviewer: "user-123" } }). The function isn't kept resident in memory for 24 hours — Inngest wakes it up when the event arrives, which is exactly the property serverless platforms need.
Step 5: fan-out and fan-in for batch work
"Summarize 100 documents in parallel, then synthesize one report" is a textbook AI workflow. Inngest's step.invoke() spawns child functions, and you collect them with Promise.all().
The concurrency caps (10 children, 20 parents) come from balancing Anthropic's rate limit against the downstream database load. From experience, Haiku-heavy batches are safe at ~20 concurrent calls, Sonnet-heavy batches at 5–10. Tune to your real rate limits.
You can't operate what you can't observe
Durability won't save you if you can't see what's happening. Inngest's UI shows every function execution and lets you replay a failure with one click, but business metrics need separate tracking. I always log three things:
Input and output tokens. Bake response.usage into the step return value so you can aggregate later.
Tool call frequency by name. Tells you which tool to optimize first.
Claude retry attempts. The attempt argument to a step's handler exposes Anthropic outage trends.
Pipe the logs through Vercel Log Drains to BigQuery or Honeycomb and monthly cost analysis becomes painless. If you're using prompt caching, watch the cache_read_input_tokens ratio carefully — once it crosses 50%, Anthropic costs visibly drop. Halving your Claude API monthly bill with prompt caching covers that pattern.
Production gotchas I've actually hit
The mistakes I made on the way here, and what I do now.
Gotcha 1: Random values inside step.run
// ❌ Dangerous: re-runs produce different IDsconst requestId = await step.run("save", async () => { const id = crypto.randomUUID(); // generated every retry await db.requests.create({ data: { id, /* ... */ } }); return id;});
Step bodies can be re-evaluated on retry. Generate IDs and timestamps before the step, not inside it.
If a tool returns a fat JSON blob, the next Claude turn can hit context-window limits and 400 out. I once stuffed web_search results raw into messages and crashed at turn five. The fix is a small Haiku-powered compression step before re-feeding the result to Claude.
const compressed = await step.run(`compress-${tu.id}`, async () => { if (JSON.stringify(rawResult).length < 4000) return rawResult; const res = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 512, messages: [ { role: "user", content: `Extract only information relevant to "${topic}" from these search results:\n\n${JSON.stringify(rawResult)}`, }, ], }); return res.content[0].type === "text" ? res.content[0].text : "";});
Use Haiku, not Sonnet — otherwise the cost math inverts and your savings from caching evaporate.
Gotcha 3: renaming function IDs orphans in-flight runs
createFunction({ id: "old-id" }, ...) — change id and Inngest treats it as a brand-new function. Anything that was running under the old ID is now stranded with no code to resume into. Don't casually rename function IDs. If you must, deploy both versions in parallel for at least 24 hours, or manually mark the old executions complete in the Inngest UI.
Gotcha 4: Cloudflare Workers CPU limits
On Cloudflare Workers, even paid tiers cap CPU time per request. A synchronous parse of a 1 MB string after a Claude response is enough to trip the limit. Splitting heavy CPU work into its own step (or yielding via an await) usually fixes it. I once burned a week debugging this before splitting a JSON.parse into a downstream step.
Gotcha 5: parallelism without backpressure
Firing 100 concurrent calls with Promise.all() on a Tier 1 Anthropic key gets you instant 429s. Set concurrency.limit on the function definition. Tier 2 keys can handle ~20 Sonnet calls or ~50 Haiku calls in parallel as a starting point. For a deeper dive, Running parallel Claude API requests safely with Python asyncio covers the same ground for Python developers.
A real-world wiring: trigger, log, and inspect
Theory is fine, but here's what the end-to-end flow looks like in production. Suppose a user submits a research request from a form on your Next.js app. The request handler doesn't run the agent inline — it just emits an event:
Two things to notice. First, the route returns immediately — the user isn't waiting for the multi-turn agent loop. Second, the jobId is generated outside Inngest so the client has a stable handle to poll. Polling endpoints can either look at your own job table (updated by the agent function as it progresses) or call Inngest's REST API to query function status.
For status updates, I usually have the agent function write checkpoints into its own table:
Now your UI can poll /api/research/${jobId}/status and surface progress without ever talking to Inngest directly. This separation — Inngest handles execution, your database handles user-facing state — keeps the architecture simple as the team grows.
When durability is the wrong tool
Not every Claude call belongs inside a workflow. Wrapping a chat endpoint that responds in two seconds inside Inngest adds latency and makes debugging harder for no real payoff. Here are the heuristics I use:
Real-time chat UX: keep it inline. Users expect token-streaming responses; routing through a durable engine breaks the streaming experience and adds round trips.
Single-call agents that finish in under 30 seconds and don't touch destructive tools: keep it inline. The risk profile doesn't justify the indirection.
Multi-turn agents, batch jobs, anything that touches billing or messaging: send it to Inngest.
The cost of durability isn't free. You pay in cognitive overhead (now there are two control planes), in latency (every step boundary serializes data through Inngest's persistence layer), and in onboarding friction for new teammates. The benefit only beats the cost when failure has real consequences. Be honest about which workflows actually fit that profile, and resist the temptation to wrap everything in step.run "just in case."
Why pick Inngest over Temporal
By now you might be wondering whether Temporal would do all this just as well. Here's how I choose:
Pick Inngest when you're TypeScript-first, your stack is Next.js/SvelteKit/Hono, you want everything to run on Vercel or Cloudflare without extra infrastructure, and your workflows live for minutes to hours.
Pick Temporal when you have mixed Python/TypeScript codebases, workflows that run for days or weeks, you need strict deterministic replay, and you have an SRE team that can own the Temporal cluster.
Inngest doesn't keep your worker resident, so it slots cleanly into serverless. Temporal's deterministic replay (bit-for-bit reproducing a workflow from its event history) is stricter, which matters less for AI workflows where the model itself is non-deterministic anyway. For my use case — small team, serverless infrastructure, AI-heavy logic — Inngest is the obvious fit.
Testing durable workflows without going insane
Durable execution is hard to test if you treat each step as opaque. The trick I've settled on is to keep step bodies thin enough that they can be unit-tested in isolation, and to write a small harness that runs Inngest functions in-memory.
// test/agent.test.tsimport { describe, it, expect, vi } from "vitest";import { runResearchAgent } from "@/inngest/functions/research-agent";vi.mock("@/inngest/functions/tools", () => ({ runTool: vi.fn(async (name: string) => { if (name === "web_search") return [{ title: "fixture", snippet: "test", url: "https://example.com" }]; return { ok: true }; }),}));describe("runResearchAgent", () => { it("converges within 6 turns and returns final messages", async () => { const ctx = makeFakeStepCtx(); const result = await runResearchAgent.fn(ctx); expect(result.ok).toBe(true); expect(result.finalMessages.length).toBeGreaterThan(1); expect(ctx.steps.size).toBeLessThanOrEqual(15); });});
The makeFakeStepCtx() helper produces a step object whose run() simply executes the body and remembers the step ID — good enough for verifying the loop's logic and that step IDs are unique. For end-to-end tests, the Inngest dev server lets you fire real events and assert on their final state.
Two testing principles that have saved me real time:
Always include a hard turn cap in the test fixture. If the model loops indefinitely on a fixture, your CI run will hang. Fail fast at six turns and surface that as the bug.
Snapshot the message array before each Claude call. When a regression appears, the snapshot tells you exactly which prompt context produced the divergence.
If you've made it this far, here's the single concrete step I'd recommend: take the scariest tool call in your existing Claude code — the Stripe charge, the email send, the destructive POST — and wrap just that one call in step.run(). Don't try to rewrite the whole agent.
Adding durability to one dangerous operation reduces the number of cold-sweat 3am debugging sessions immediately. That's how I migrated my own systems: one risky tool at a time, over about six months. By the end I couldn't imagine running production AI workflows any other way. If you're putting Claude in production at all, you'll feel "we should have had a workflow engine from day one" sooner than you think.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.