●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
Implementing the Transactional Outbox Pattern with Claude Agent SDK — Eliminating Lost Side Effects in Production
Stop the 'the row was inserted but the email never went out' class of bugs in Claude Agent SDK apps. A production-grade walkthrough of the Transactional Outbox pattern using Postgres and Cloudflare Queues.
If you have shipped anything serious with Claude Agent SDK, you have probably seen the bug at least once. The order row exists. The user never got an email. Or worse, you charged the card but never wrote the receipt to your database. Retries help with the inverse failure (running the same side effect twice), but they cannot conjure a side effect that never started.
I learned this running membership flows for four sites on top of Claude Agent SDK and Stripe. In the first three months I caused three near-incidents because a tool function did db.insert(...) immediately followed by sendEmail(...), and a SDK timeout between those two lines was enough to silently drop the email forever. Idempotency keys do not save you here — you cannot deduplicate a call that was never made.
The structural fix for this is the Transactional Outbox pattern. It is well-known in microservices literature, but most write-ups assume a long-running Java service, not a Claude agent loop where a tool call is the unit of work. This guide is the version I wish I had: a concrete, copy-pasteable Postgres + Cloudflare Queues setup that drops into a Claude Agent SDK app and turns "the side effect must run" from a wish into an invariant.
Why Agent SDK apps lose side effects
A few failure modes show up over and over once an agent is in production.
A tool body crashes between two awaits. The DB write to orders succeeded; the call to mailer.send was about to start when the worker hit its CPU limit or timed out. The DB now claims an order exists that no human has been notified about.
A model response gets corrupted and the agent loop restarts. From the model's perspective, the previous tool already returned a result, so it never asks for it again. The side effect that never ran is now invisible to the LLM as well.
A long-running agent is paused and resumed the next day. Claude Agent SDK happily restores conversation state, but any "I will send this email when convenient" intent that was only living in process memory is gone.
Idempotency keys (covered in Designing idempotency for Claude Agent SDK) ensure a side effect runs at most once. The Outbox ensures it runs at least once. You need both, but the Outbox is the harder of the two to retrofit, so it deserves its own treatment.
What the pattern actually is
In one sentence: instead of performing a side effect directly, write a request for that side effect into an outbox table inside the same database transaction as your business write. A separate dispatcher reads the outbox and forwards the request to a queue. Consumers do the real work.
[Agent tool]
│
├─ BEGIN
├─ INSERT INTO orders ...
├─ INSERT INTO outbox (topic, payload) VALUES ('mail.send', ...)
└─ COMMIT
│
▼
[Outbox dispatcher]
│
├─ SELECT pending rows
├─ Publish to Cloudflare Queues / SNS / Kafka
└─ UPDATE outbox SET dispatched_at = now()
│
▼
[Consumer]
└─ Sends email, charges card, posts to Slack, etc.
Because the business row and the side-effect intent are committed atomically, "if the row exists, the intent exists" is now a database invariant. The dispatcher only has to deliver the intent at-least-once. Combined with idempotent consumers, you get effectively-once semantics without a distributed transaction.
✦
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
✦You will be able to architect Claude Agent SDK apps so that crashes mid-tool no longer leave you with 'the order was saved but Stripe was never called' inconsistencies
✦You will walk away with a copy-paste-ready Postgres outbox table, a Cloudflare Queues dispatcher, and a consumer skeleton you can drop into any TypeScript project today
✦You will know how to monitor outbox lag, choose partition keys, and migrate existing tools incrementally without a big-bang rewrite
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.
You can ship with a small schema, but a few extra columns will save you from regretting cuts later.
CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, aggregate_id TEXT NOT NULL, -- e.g., order id; doubles as partition key topic TEXT NOT NULL, -- 'mail.send', 'stripe.charge', ... payload JSONB NOT NULL, headers JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), available_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- delayed delivery & backoff dispatched_at TIMESTAMPTZ, attempts INT NOT NULL DEFAULT 0, last_error TEXT);-- Partial index that only covers undispatched rowsCREATE INDEX outbox_pending_idx ON outbox (available_at) WHERE dispatched_at IS NULL;-- For maintaining per-aggregate orderingCREATE INDEX outbox_aggregate_idx ON outbox (aggregate_id, id) WHERE dispatched_at IS NULL;
The available_at column lets you reuse the outbox for delayed delivery ("send the welcome email three minutes after signup") and for retry backoff. The headers column is where you stash trace IDs, schema versions, and tenant IDs — fields you will desperately want when debugging six months from now.
The detail I most underestimated on my first attempt was the partial index. Without WHERE dispatched_at IS NULL, dispatcher queries fall off a cliff once the table passes a million rows. Always index for the unfinished work, not the historical record.
Atomically writing the outbox row from a tool
The Claude Agent SDK tool stops calling external APIs directly. Its only job is to write business state and outbox rows in one transaction.
// tools/createOrder.tsimport { tool } from "@anthropic-ai/agent-sdk";import { z } from "zod";import { db } from "../db";export const createOrder = tool({ name: "create_order", description: "Create an order and enqueue all related side effects in the outbox.", input_schema: z.object({ user_id: z.string(), amount: z.number().int().positive(), currency: z.enum(["JPY", "USD"]), idempotency_key: z.string(), // generated by your application, not the model }), async run(input, ctx) { return await db.transaction(async (tx) => { // 1) Business row const order = await tx .insertInto("orders") .values({ user_id: input.user_id, amount: input.amount, currency: input.currency, idempotency_key: input.idempotency_key, status: "pending", }) .onConflict((oc) => oc.column("idempotency_key").doNothing()) .returningAll() .executeTakeFirst(); if (!order) { return { ok: true, deduped: true }; } // 2) Side-effect intents in the same transaction await tx.insertInto("outbox").values([ { aggregate_id: order.id, topic: "stripe.charge", payload: { order_id: order.id, amount: order.amount, currency: order.currency, }, headers: { trace_id: ctx.trace_id, schema_version: 1 }, }, { aggregate_id: order.id, topic: "mail.send", payload: { template: "order_received", user_id: order.user_id, order_id: order.id, }, headers: { trace_id: ctx.trace_id, schema_version: 1 }, }, ]).execute(); return { ok: true, order_id: order.id }; }); },});
When this tool returns, the row in orders and the two outbox rows have committed together. If the agent loop dies the next instant, your data still tells the truth: the order exists and the intent to charge it and to email the user is recorded. The agent does not need to know what happens next.
The non-negotiable rule is that no tool ever calls Stripe, SES, Slack, or any other side-effect-producing API directly. The moment that rule is broken even once, your outbox guarantees stop being guarantees. A code-review heuristic that worked well for me: if you see fetch, axios, or any vendor SDK call inside a tool body, the PR fails review.
Building a polling dispatcher
Start simple. A polling dispatcher running every minute is enough for almost every project for the first year. Cloudflare Workers Cron Triggers make this nearly free to operate.
// dispatcher/poll.tsimport { db } from "../db";import { sendToQueue } from "../queue";export async function dispatchBatch(now = new Date()) { const rows = await db.transaction(async (tx) => { const pending = await tx .selectFrom("outbox") .selectAll() .where("dispatched_at", "is", null) .where("available_at", "<=", now) .orderBy("id", "asc") .limit(200) .forUpdate() // row-level lock .skipLocked() // makes parallel dispatchers safe .execute(); return pending; }); for (const row of rows) { try { await sendToQueue(row.topic, { id: String(row.id), aggregate_id: row.aggregate_id, payload: row.payload, headers: row.headers, }); await db .updateTable("outbox") .set({ dispatched_at: new Date() }) .where("id", "=", row.id) .execute(); } catch (e) { const next = backoff(row.attempts); await db .updateTable("outbox") .set({ attempts: row.attempts + 1, last_error: String(e), available_at: new Date(now.getTime() + next), }) .where("id", "=", row.id) .execute(); } }}function backoff(attempts: number) { const base = Math.min(30_000 * 2 ** attempts, 15 * 60_000); return base + Math.floor(Math.random() * 5_000);}
FOR UPDATE SKIP LOCKED is what makes horizontal scaling safe. With it, you can run ten dispatchers in parallel and Postgres guarantees a single outbox row will not be picked up twice. Without it, multiple dispatchers will race and you will deliver the same intent N times — which is technically fine if your consumer is idempotent, but defeats the elegance of the design.
I export SELECT count(*) FROM outbox WHERE dispatched_at IS NULL as a per-minute Prometheus gauge. Healthy graphs hover near zero with brief spikes during traffic bursts. Anything that stays elevated for ten minutes is a signal that the dispatcher is broken or that downstream consumers are slow.
Ordering and partition keys
"Process events for the same order in the order they were created" is a constraint that comes up everywhere. The outbox itself returns rows in insert order, but ordering can be lost downstream of the queue unless you wire it carefully.
In Cloudflare Queues, SQS FIFO, and Kafka, you guarantee per-key ordering by passing a partition key (or MessageGroupId). Use the aggregate_id from the outbox row.
If your topics have very different ordering needs, split them across queues. Email sending tolerates reordering; a charge followed by a refund does not. Two queues are cheaper than a single queue with hand-rolled sequencing.
When to graduate to CDC
Polling has a floor of a few hundred milliseconds to a few seconds of dispatch lag. If you outgrow that, switch to log-based change data capture: have Debezium read Postgres logical replication and stream outbox inserts into your queue directly. Lag drops to tens of milliseconds.
Do not start there. I ran polling for the first six months and only migrated when p95 dispatch latency went above five seconds during traffic spikes. CDC adds real operational weight: replication-slot monitoring, schema-change choreography, and a separate runtime to babysit. Earn that complexity by feeling polling's pain first.
What to instrument
If you ship with three metrics, you have covered the most likely incidents.
outbox_pending_total is a gauge you scrape every minute. outbox_dispatch_latency_seconds is a histogram of dispatched_at - created_at. outbox_attempts_histogram tells you how often you are retrying.
For alerts: page when outbox_pending_total > 1000 for five minutes, page when p95(dispatch_latency) > 30s for ten minutes, page when any row crosses attempts > 5. Those three rules will catch nearly every Outbox-shaped incident I have seen.
The single biggest debugging accelerant for me has been the last_error column. When a dispatcher is failing repeatedly, having the most recent stringified error in the row itself cuts investigation time by an order of magnitude. Log streams are easy to lose; a column on the row in question is not.
Anti-patterns and traps
A few things will bite you. Knowing about them in advance is the cheapest version of learning them.
Mixing direct side effects with outbox-routed side effects is the worst state to be in. "Just this once, in a hurry" stacks up until nobody can answer the question of which calls go through the outbox. Make the lint rule a hard rule: if a tool calls an external API directly, the PR is rejected.
Forgetting that consumers must be idempotent is the second classic mistake. The outbox is at-least-once. Assume every message is delivered twice and design the consumer accordingly. Without idempotent consumers, a retry sends two emails or charges the card twice.
Letting the outbox grow forever turns a fast lookup into a slow scan. Run a daily job that deletes or archives rows where dispatched_at IS NOT NULL AND created_at < now() - interval '30 days'. Partial indexes keep the hot path fast, but full-table vacuuming is not free.
Trying to drive dispatch latency below a second by shrinking the cron interval rarely works. Cloudflare Workers Cron is one-minute granularity at best, and a tighter scheduler increases FOR UPDATE contention faster than it improves throughput. If you need sub-second lag, that is the signal to consider CDC, not faster polling.
Rolling it out without a big-bang migration
Greenfield projects can adopt the outbox on day one. Existing agent code needs a phased rollout. The order I used:
Deploy the outbox table on its own and let it sit unused for a week to confirm migrations and backups work cleanly. Pick a single low-risk topic — for me, the welcome email — and convert that one tool from sendMail(...) to outbox.insert(...) plus a new dispatcher and consumer. Watch metrics for a week before adding the next topic. Move Slack notifications and similar non-critical topics next. Save payments, inventory decrements, and anything financial for last.
Once every topic is migrated, lock the rule in with CI tooling. The article Running ESLint safely with Claude Code covers a workable pattern for adding a custom lint rule that bans direct external calls inside tool bodies.
That sequencing has shipped for me without a single production incident. The version where I tried to migrate every topic at once did not.
A consumer skeleton you can adapt
The dispatcher's job ends at "I gave the message to the queue." The consumer is what actually performs the side effect, and it has to be idempotent because the outbox is at-least-once. Here is the shape I use, adapted for a Cloudflare Queues consumer in TypeScript.
// consumers/mailConsumer.tsimport type { MessageBatch } from "@cloudflare/workers-types";import { db } from "../db";import { mailer } from "../mailer";interface OutboxMessage { id: string; // outbox row id, used as idempotency key aggregate_id: string; payload: { template: string; user_id: string; order_id: string; }; headers: { trace_id?: string; schema_version: number; };}export default { async queue(batch: MessageBatch<OutboxMessage>, env: Env) { for (const msg of batch.messages) { const body = msg.body; try { // Idempotency check — was this outbox id already processed? const seen = await db .selectFrom("processed_messages") .select("id") .where("id", "=", body.id) .executeTakeFirst(); if (seen) { msg.ack(); continue; } // Real side effect await mailer.send({ template: body.payload.template, user_id: body.payload.user_id, metadata: { order_id: body.payload.order_id, trace_id: body.headers.trace_id }, }); // Record that we processed this message await db .insertInto("processed_messages") .values({ id: body.id, processed_at: new Date() }) .onConflict((oc) => oc.column("id").doNothing()) .execute(); msg.ack(); } catch (e) { // Let the queue retry — do NOT ack msg.retry({ delaySeconds: 60 }); } } },};
The processed_messages table is the consumer's idempotency ledger. It is small (you can prune it on a 14-day window) and it makes the consumer safe against the queue redelivering a message after a transient failure. If you have a mailer that already supports an idempotency key on its API (Resend and Postmark both do), you can pass body.id and skip the ledger entirely.
The reason I emphasise "do not ack on failure" is that a swallowed exception is the easiest way to silently lose a message in the very pattern that exists to prevent silent message loss. Make your consumer's failure mode loud.
Backfilling existing data when you adopt the pattern
Adopting Outbox in a greenfield app is easy. Adopting it in an app that has been running for a year is the harder problem, because you may already have business rows for which the side effect either ran, did not run, or you are not sure. Three migration strategies have worked for me, in order of preference.
The simplest is switch on a date. All orders created after the rollout cut go through Outbox; all orders before that cut keep their old code path. For most products this is fine because you do not need to backfill — those side effects have either fired or they never will, and your users are not paying you to revive month-old missed emails.
The second is dual-write during transition. Both code paths run for a window, with the Outbox path tagged in the dispatcher to short-circuit if a flag in the payload says "shadow mode." This lets you compare the two paths in production without affecting users. After a week of identical behaviour you flip the cut and remove the legacy path.
The third — and the one that earns the most production maturity points — is to reconstruct intent from existing rows. Run a one-off script that scans orders where welcome_email_sent_at IS NULL AND created_at > now() - interval '7 days' and inserts retroactive outbox rows for them. Done carefully, this can recover side effects that were lost before the pattern was adopted. Done carelessly, it sends three weeks of welcome emails to users in one afternoon. Always run it with a --dry-run first.
Cloudflare-specific gotchas
If your stack is Cloudflare Workers with D1 or an external Postgres, a few constraints are worth flagging up front.
D1 does not currently support FOR UPDATE SKIP LOCKED. If you are on D1 and want to scale beyond a single dispatcher, you need to fake row locking with a separate outbox_lease table or shard the work by id % N. For most low-volume agents, a single dispatcher Worker on a Cron Trigger is fine; revisit only when your dispatcher cannot keep up.
Cloudflare Workers Cron is one-minute granularity. If you need lower latency, the supported pattern is to run a long-lived Durable Object that polls every few seconds, or to switch to CDC. Do not try to fake a higher-frequency cron with multiple staggered triggers — it does not work the way you think and it complicates incident response.
Cloudflare Queues has a per-consumer batch size limit and a 30-second processing window per batch. If your downstream call is slow, prefer many small batches over few large ones, and be defensive about timeouts inside the consumer.
The reverse-proxy headers from Workers do not propagate trace IDs by default. Add cf-trace-id (or your own header convention) at the edge and pass it through the outbox headers field. Future-you, paged at midnight, will be deeply grateful.
Testing the pattern in CI
Outbox is one of the patterns that genuinely benefits from contract tests. The two most useful tests I have written are these.
A transaction-rollback test confirms that if the inner business write fails, no outbox row is left behind. Wrap the tool call in a forced exception after the second insert and assert that the count of outbox rows is unchanged. This catches the most common refactoring mistake — accidentally taking the INSERT INTO outbox out of the transaction wrapper.
A dispatcher-crash test confirms that if the dispatcher dies between sending to the queue and updating dispatched_at, the next run picks up the row again. Mock the queue to succeed and the database update to throw, then run a second pass and assert the message was sent twice. This proves that your at-least-once guarantee is real.
Both tests are short — under fifty lines — and they regress immediately when someone tries to "optimise" away the transactional boundary. Make them mandatory in CI.
How much does this cost?
The honest answer is "almost nothing if your scale is small, and still cheap at large scale." For my four sites combined I am at roughly 50,000 outbox rows per month. Postgres handles that without breaking a sweat — the table is under 200 MB including indexes. Cloudflare Queues at this volume is well under a dollar a month.
Where costs do show up is in latency-sensitive operations that you converted from synchronous to asynchronous. A signup flow that used to send the welcome email synchronously will now finish faster (good for the user) but the email itself might arrive a second or two later (almost always fine). For 99% of products this is a net win. The exception is interactive flows where the user is waiting on the side effect — for those, do not convert them at all. If your consumer ends up calling Anthropic's API in a hot loop, layering a budget circuit breaker for Claude API on top of the outbox prevents a runaway retry from quietly bankrupting you.
When not to use Outbox
To round out the picture: there are situations where Outbox is the wrong answer.
If your side effect is a read-only query against the same database as your business write, you do not need Outbox at all — the read can simply re-fetch state when needed. Outbox is for outbound events that leave the system.
If the side effect must complete synchronously (a payment confirmation that the user is waiting on), pushing it through a queue inserts latency. In those cases prefer the Saga pattern with explicit compensations, or call the external API synchronously and accept that you need a separate reconciliation job to catch failures.
If your business writes do not happen in a relational database — you are using DynamoDB or Spanner without strong cross-row transactions — the "atomic write of business state and outbox row" property does not hold for free, and you have to lean on conditional writes or transactions specific to that store.
In all other cases, the cost of running an Outbox is low and the upside is enormous. The first incident it prevents pays for the rest of the year.
Comparison with three alternative patterns
Outbox is not the only way to make side effects reliable. The three most commonly suggested alternatives are worth comparing directly so you know when each one wins.
The two-phase commit (2PC) approach makes the database write and the external call participate in a distributed transaction so they either both happen or neither does. In theory this is the cleanest answer. In practice it requires every external service to support 2PC (Stripe, SES, and most modern APIs do not), it is slow, and it tightly couples your availability to the participants. I have never used it in an Agent SDK app and would not recommend it.
The Saga pattern with compensations keeps everything synchronous. You call Stripe; if a later step fails, you call Stripe's refund endpoint to compensate. Sagas are the right choice when the side effect is fast, the user is waiting on the outcome, and a meaningful "undo" exists. They are the wrong choice when the side effect has no compensation (an email that has been read cannot be unread) or when the call itself is slow enough that you cannot reasonably hold a request open.
The fire-and-forget with reconciliation approach lets the side effect run synchronously and runs a periodic job to detect missed ones (rows in orders without a matching email log entry, for instance) and replay them. This is the lightest-weight pattern and works well for small projects, but it depends on the ability to detect a missed side effect after the fact, which is not always possible.
Outbox sits in the middle of these. It is more elegant than reconciliation, more practical than 2PC, and more general than Saga. For the typical Claude Agent SDK use case — non-trivial side effects that should run exactly once and where the user is not blocked on the outcome — it is the right default.
A small philosophical aside
The most useful mental shift Outbox forced on me is treating the database as the source of truth for intent as well as for state. Most engineers naturally think of the database as where the world's facts live: the orders that exist, the users that exist. Outbox extends that to "the things that should happen." Once that intent is committed, you cannot lose it without losing the row that depends on it, and that constraint propagates through the rest of the architecture in a way that is genuinely calming.
Working with Claude Agent SDK has made this even more important to me. Agents are inherently flaky — they retry, they resume, they restart, they time out. The architecture has to do the work of making side effects reliable because the agent itself cannot be relied on to do it. Outbox is, in a sense, the database doing the agent's job for it.
I find it satisfying that a pattern from 2010s microservices literature turns out to be exactly the right shape for 2026 LLM agents. The reasons it works are the same — the boundary between "what we decided" and "what the world knows about what we decided" is the boundary that needs the strongest guarantees. Whether the actor doing the deciding is a Java service or a Claude agent loop turns out not to matter much.
One thing to do today
Pick one side effect from one of your existing tools and route it through an outbox. Just one. The rest of the design is easier to absorb after you have watched a single message flow from INSERT INTO outbox through a dispatcher and into a consumer.
Thank you for reading. If this saves even one engineer from a 3 a.m. page about a missing email, it has paid for itself.
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.