{"slug": "your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects", "title": "Your AI agent re-sends the email on retry: an outbox for side effects", "summary": "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.", "body_md": "Most agent frameworks model a step as *\"do some work, then call the tools.\"*\n\nSo the tool call — send the email, call the webhook, charge the card — happens\n\n**inside** the run, somewhere in the middle of your graph.\n\nThat works until it doesn't. The moment you add a retry, a crash-resume, or a\n\ndeterministic replay, the same step runs again, and the side effect fires again.\n\nState you can version and reproduce; the world you can't.\n\nThis post is about the **outbox** I shipped in\n\n[reactifact](https://github.com/bzdvdn/reactifact) 0.14.0 — still the design in\n\nthe current 0.15.x line. The produce records *what should happen* as an\n\nartifact in the context (committed atomically with everything else); the runtime\n\ndelivers it **after** the commit, once per stable id. Replay reconstructs the\n\nanswer without re-sending anything.\n\n**TL;DR** — Don't do I/O inside an agent step. Record the *intent* as state\n\n(committed atomically with everything else) and let the runtime deliver it\n\nonce, after the commit. Retry, resume and replay then rebuild the decision\n\nwithout re-sending.\n\nRunnable in about a minute, no API key:\n\n```\npip install reactifact\npython -m examples.outbox.main   # seven cases, each one asserted\n```\n\nCode: [github.com/bzdvdn/reactifact](https://github.com/bzdvdn/reactifact)\n\nIf you know the graph frameworks, the difference is where the effect lives:\n\n|  | Typical agent step | reactifact | \n|---|---|---|\n| The step does | calls the tool *mid-run* | writes the intent as **state** | \n| On retry / resume | fires the side effect again | re-derives the same stable id → no second send | \n| On replay | re-executes → re-sends | reconstructs the record → never re-sends | \n| On parallel/merge | two commits, two sends | one intent, one delivery | \n\nHere's the shape that causes trouble. A produce reacts to an order and sends a\n\nconfirmation email:\n\n``` python\nasync def process_order(order):\n    email.send(order.customer, \"Your order is confirmed\")   # side effect, mid-run\n    return Receipt(order_id=order.id)\n```\n\nNow consider the three things every long-lived agent eventually needs:\n\nYou can try to guard it. A `create_once(...)`-style check helps for state, but the\n\nguard resolves at *commit time*, and two producers in one generation share the\n\nsame pre-commit snapshot — so both pass the guard and both send. The check is in\n\nthe wrong place: it's protecting the *write*, while the side effect happens\n\n*before* the write.\n\nThe outbox inverts the order:\n\n`PendingAction` — and\ndoes In reactifact that reads like this:\n\n``` python\nfrom reactifact import PendingAction, ProduceCall, produce\n\n@produce(Receipt, also_creates=[PendingAction])\nasync def process_order(call: ProduceCall) -> None:\n    order = call.trigger\n\n    # The outbound side effect: recorded, not performed. The key is derived\n    # from the order id, so a re-run or a merged branch reuses the same intent.\n    call.effects.act(\n        \"notify\",\n        key=f\"notify:{order.data.id}\",\n        payload={\"to\": order.data.customer, \"order\": order.data.id},\n    )\n    call.effects.upsert(\n        Receipt(order_id=order.data.id, text=\"confirmed\"),\n        id=f\"receipt:{order.data.id}\",\n    )\n    return None   # nothing is applied until the runtime compiles the effects\n```\n\n`effects.act(...)` creates a `PendingAction` under the stable id\n\n`action:{key}` and returns `None` if one already exists (an idempotent re-run).\n\nNote what the produce does *not* do: it never touches the network. It only states\n\na change.\n\nDelivery is a separate, injected step:\n\n``` python\nasync def dispatch(context, action):\n    # a real one calls your email/webhook API here\n    if action.data.idempotency_key in sent:\n        return\n    sent.append(action.data.idempotency_key)\n\nruntime = Runtime(ctx, agents=[Notifier()], dispatcher=dispatch)\nawait runtime.arun()\n```\n\nThe runtime drains the outbox after each generation's commit, marking each action\n\n`dispatched` — or `failed` (and re-raising) if the dispatcher throws. A failing\n\ndispatch is *state*, not a lost effect.\n\n`Context` `effects.act` returns `None` and no second intent exists.`dispatched` side wins regardless of which branch is the merge target — so\na merge can never resurrect an already-sent action. Divergent payloads under\nthe same id are an explicit merge You get the operational story you'd want from a queue — but the \"queue\" is just\n\nversioned state you already have.\n\nThis is deliberately not a message broker, and the limitations are worth stating\n\nplainly:\n\n`idempotency_key` you pass to the\nexternal system, which dedupes on its side. That is the same contract every\nreal outbox relies on.`arun()` or an explicit\n`flush_pending_actions()` drains the outbox; the framework doesn't run a poller\nfor you. Retry and backoff policy stay with the application (wrap your\ndispatcher), because that's a product decision, not a framework reflex.\nThat's the point of the split: state is versioned and reproducible, the world\n\nisn't, and the boundary between them should be something you *declare* rather\n\nthan something buried in the middle of a function.\n\nThe whole thing ships as an executable, no-API-key demo. `examples/outbox` walks\n\nseven cases and asserts each one:\n\n```\n.venv/bin/python -m examples.outbox.main\nphp\n1. commit -> dispatch        sent=['notify:42'] status=dispatched\n2. re-derivation             first run sent 1; the re-run sent 0\n3. same generation           producers=2 intents=1 sent=1\n4. two merged branches       branches=2 intents-after-merge=1 sent=1\n5. replay                    sent before replay=1 after replay=1\n6. failure -> retry          raised 'smtp temporarily unavailable'; retry sent once\n7. app-owned retry           transient outage absorbed by the wrapper\n```\n\nIt shipped in 0.14.0, alongside correlated structured logging and a hard\n\nper-turn deadline with graceful shutdown (the current release is 0.15.x):\n\n`docs/en/patterns.md#outbox-external-side-effects` and\n`docs/en/durability.md#outbox-in-production`\nThat's half the story: the outbox keeps an external effect from firing twice.\n\nThe other half — making the *state* behind an answer inspectable and\n\nreproducible, so you can hash it, replay it and audit the trace — is the\n\n[next post](https://dev.to/bzdvdn/auditable-agents-turn-the-answer-into-a-claim-you-can-check-2lm4-temp-slug-3862381).\n\nIf you're building agents that touch the outside world, I'd genuinely like to\n\nknow: **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.", "url": "https://wpnews.pro/news/your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects", "canonical_source": "https://dev.to/bzdvdn/your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects-13id", "published_at": "2026-10-09 23:31:22+00:00", "updated_at": "2026-10-09 23:58:19.545875+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["reactifact"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects", "markdown": "https://wpnews.pro/news/your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects.md", "text": "https://wpnews.pro/news/your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects.txt", "jsonld": "https://wpnews.pro/news/your-ai-agent-re-sends-the-email-on-retry-an-outbox-for-side-effects.jsonld"}}