Your AI agent re-sends the email on retry: an outbox for side effects A developer shipped an outbox pattern in reactifact 0.14.0 (still present in 0.15.x) that records side-effect intents as committed state rather than performing I/O mid-step, so retries, crash-resumes and deterministic replays re-derive the same stable id and never re-send an email or webhook. The runtime drains the outbox after each generation's commit, marking actions dispatched or failed, and the accompanying example runs seven asserted cases without an API key. Most agent frameworks model a step as "do some work, then call the tools." So the tool call — send the email, call the webhook, charge the card — happens inside the run, somewhere in the middle of your graph. That works until it doesn't. The moment you add a retry, a crash-resume, or a deterministic replay, the same step runs again, and the side effect fires again. State you can version and reproduce; the world you can't. This post is about the outbox I shipped in reactifact https://github.com/bzdvdn/reactifact 0.14.0 — still the design in the current 0.15.x line. The produce records what should happen as an artifact in the context committed atomically with everything else ; the runtime delivers it after the commit, once per stable id. Replay reconstructs the answer without re-sending anything. TL;DR — Don't do I/O inside an agent step. Record the intent as state committed atomically with everything else and let the runtime deliver it once, after the commit. Retry, resume and replay then rebuild the decision without re-sending. Runnable in about a minute, no API key: pip install reactifact python -m examples.outbox.main seven cases, each one asserted Code: github.com/bzdvdn/reactifact https://github.com/bzdvdn/reactifact If you know the graph frameworks, the difference is where the effect lives: | | Typical agent step | reactifact | |---|---|---| | The step does | calls the tool mid-run | writes the intent as state | | On retry / resume | fires the side effect again | re-derives the same stable id → no second send | | On replay | re-executes → re-sends | reconstructs the record → never re-sends | | On parallel/merge | two commits, two sends | one intent, one delivery | Here's the shape that causes trouble. A produce reacts to an order and sends a confirmation email: python async def process order order : email.send order.customer, "Your order is confirmed" side effect, mid-run return Receipt order id=order.id Now consider the three things every long-lived agent eventually needs: You can try to guard it. A create once ... -style check helps for state, but the guard resolves at commit time , and two producers in one generation share the same pre-commit snapshot — so both pass the guard and both send. The check is in the wrong place: it's protecting the write , while the side effect happens before the write. The outbox inverts the order: PendingAction — and does In reactifact that reads like this: python from reactifact import PendingAction, ProduceCall, produce @produce Receipt, also creates= PendingAction async def process order call: ProduceCall - None: order = call.trigger The outbound side effect: recorded, not performed. The key is derived from the order id, so a re-run or a merged branch reuses the same intent. call.effects.act "notify", key=f"notify:{order.data.id}", payload={"to": order.data.customer, "order": order.data.id}, call.effects.upsert Receipt order id=order.data.id, text="confirmed" , id=f"receipt:{order.data.id}", return None nothing is applied until the runtime compiles the effects effects.act ... creates a PendingAction under the stable id action:{key} and returns None if one already exists an idempotent re-run . Note what the produce does not do: it never touches the network. It only states a change. Delivery is a separate, injected step: python async def dispatch context, action : a real one calls your email/webhook API here if action.data.idempotency key in sent: return sent.append action.data.idempotency key runtime = Runtime ctx, agents= Notifier , dispatcher=dispatch await runtime.arun The runtime drains the outbox after each generation's commit, marking each action dispatched — or failed and re-raising if the dispatcher throws. A failing dispatch is state , not a lost effect. Context effects.act returns None and no second intent exists. dispatched side wins regardless of which branch is the merge target — so a merge can never resurrect an already-sent action. Divergent payloads under the same id are an explicit merge You get the operational story you'd want from a queue — but the "queue" is just versioned state you already have. This is deliberately not a message broker, and the limitations are worth stating plainly: idempotency key you pass to the external system, which dedupes on its side. That is the same contract every real outbox relies on. arun or an explicit flush pending actions drains the outbox; the framework doesn't run a poller for you. Retry and backoff policy stay with the application wrap your dispatcher , because that's a product decision, not a framework reflex. That's the point of the split: state is versioned and reproducible, the world isn't, and the boundary between them should be something you declare rather than something buried in the middle of a function. The whole thing ships as an executable, no-API-key demo. examples/outbox walks seven cases and asserts each one: .venv/bin/python -m examples.outbox.main php 1. commit - dispatch sent= 'notify:42' status=dispatched 2. re-derivation first run sent 1; the re-run sent 0 3. same generation producers=2 intents=1 sent=1 4. two merged branches branches=2 intents-after-merge=1 sent=1 5. replay sent before replay=1 after replay=1 6. failure - retry raised 'smtp temporarily unavailable'; retry sent once 7. app-owned retry transient outage absorbed by the wrapper It shipped in 0.14.0, alongside correlated structured logging and a hard per-turn deadline with graceful shutdown the current release is 0.15.x : docs/en/patterns.md outbox-external-side-effects and docs/en/durability.md outbox-in-production That's half the story: the outbox keeps an external effect from firing twice. The other half — making the state behind an answer inspectable and reproducible, so you can hash it, replay it and audit the trace — is the next post https://dev.to/bzdvdn/auditable-agents-turn-the-answer-into-a-claim-you-can-check-2lm4-temp-slug-3862381 . If you're building agents that touch the outside world, I'd genuinely like to know: where do you draw the line between a replayable computation and an external effect? Intents-as-state is one answer — I'm curious about others.