Your AI agent re-sends the email on retry: an outbox for side effects
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 deterministi

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 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.
pip install reactifact python -m examples.outbox.main # seven cases, each one asserted Enter fullscreen mode Exit fullscreen mode Code: github.com/bzdvdn/reactifact
If you know the graph frameworks, the difference is where the effect lives:
Here's the shape that causes trouble. A produce reacts to an order and sends a confirmation email:
async def process_order ( order ): email. send ( order. customer, " Your order is confirmed " ) # side effect, mid-run return Receipt ( order_id = order. id ) Enter fullscreen mode Exit fullscreen mode 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.
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 Enter fullscreen mode Exit fullscreen mode 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.
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 () Enter fullscreen mode Exit fullscreen mode 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.
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:
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 Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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):
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.
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.