{"slug": "where-should-an-ai-agent-s-idempotency-key-come-from", "title": "Where should an AI agent's idempotency key come from?", "summary": "A developer building Agent Middleware (AMW), a gateway that places a permit-and-receipt boundary in front of MCP tool calls, argues that an AI agent's idempotency key must identify the logical action rather than the individual attempt, since generating a fresh UUID per retry causes duplicate side effects like double payouts. The post recommends either storing a random key alongside the task record or deriving it deterministically via SHA-256 from stable identifiers such as run ID, step ID and business reference, and notes that AMW refuses a reused key with a different request via an idempotency_key_reused error.", "body_md": "Your agent's worker sends a payout request, and then the process gets killed before the response comes back. The supervisor restarts it. The agent reloads its plan, sees that the \"pay invoice 4417\" step never finished, and runs it again. The payment API supports idempotency keys, and the agent sent one both times. The vendor still got paid twice, because the agent generated a fresh UUID on each run.\n\nYesterday I wrote about [gating a risky tool call with a permit and a receipt](https://dev.to/chrissellers/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt-47a7) and ended by asking where an agent's key comes from. This post is my answer, and it applies whatever sits behind the tool.\n\nAn **idempotency key** is a value the client sends with a request so the server can recognize a repeat and return the first result instead of acting again. It only works if the repeat carries the same key.\n\nA **logical action** is the thing you want to happen once in the business sense: pay invoice 4417, delete the staging database created for run 812, send the renewal email for account 77. Attempts are how you try to make it happen. One logical action can take many attempts.\n\nThe rule that follows: **the key identifies the logical action, not the attempt.**\n\nMost retry code mints the key where the request is built. That is the bug.\n\nThis is a simplified sketch written for this post, not code from any product:\n\n```\n# Wrong: every attempt looks like a brand new action.\nfor attempt in range(3):\n    try:\n        return pay(invoice, idempotency_key=str(uuid.uuid4()))\n    except TimeoutError:\n        continue\n```\n\nThe timeout is exactly the case where the first attempt may have succeeded, and exactly the case where this loop sends a key the server has never seen. Moving `uuid4()` above the loop fixes the in process retry, but not the restart in the opening story, because the key dies with the process.\n\nHere is the flow I use, in order:\n\nThe fixed loop, again a sketch:\n\n```\n# Right: the key was created with the task and stored next to it.\nkey = task.idempotency_key\nfor attempt in range(3):\n    try:\n        return pay(invoice, idempotency_key=key)\n    except TimeoutError:\n        continue\n```\n\nThere are two reasonable ways to get `task.idempotency_key`.\n\n**Stored:** generate a random value when the task row or message is created, and keep it there. Simple, and safe as long as that storage is durable and is the same record every retry reads.\n\n**Derived:** compute the key from identifiers that are already stable for this action. Also a sketch:\n\n``` php\nimport hashlib\n\ndef action_key(run_id: str, step_id: str, business_ref: str) -> str:\n    raw = f\"payout:v1:{run_id}:{step_id}:{business_ref}\"\n    return hashlib.sha256(raw.encode()).hexdigest()\n```\n\nDeriving survives even if you lose the stored key, as long as the inputs are truly stable. Choose them carefully:\n\n`v1` above), so changing the recipe later does not collide with old keys.\nWith a normal service, a retry resends the same bytes. With an LLM agent, a retry often means the model generates the tool call again, and it may not produce the same arguments. The memo field gets reworded, or the amount is parsed slightly differently from the invoice text.\n\nIf your key is derived from the task, a properly built server will see the same key with a different request and refuse it. That is the system working: it failed closed instead of paying twice. Two things make this smooth in practice:\n\nI build Agent Middleware (AMW), a gateway that puts a permit and receipt boundary in front of MCP tool calls, so here is how it treats keys today. Agents act under a permit; a same-key retry returns the original receipt, no second call or charge. Governed calls must carry a key, scoped per wallet. The same key with a different request is refused with `idempotency_key_reused`, and a retry that arrives while the first call is still running gets `idempotency_in_progress`.\n\nWhat it does not do matters just as much for this topic:\n\n`idempotency_in_progress` for about three hours until the stuck call is reconciled. Your agent should treat that as \"pending\", not as \"failed, try a new key\".\nI'm Chris, the founder and sole owner of AMW, which is pre-revenue. This post was drafted with help from an AI assistant; the Python is illustrative and is not AMW's source, and the AMW claims were checked against its code. More at [thisisatest.tech](https://www.thisisatest.tech).\n\nMy question for you: **when an agent's plan changes partway through, say the amount gets corrected after a failed attempt, do you treat it as the same action and resolve the conflict, or as a new action with a new key?** I go back and forth on where that line belongs.", "url": "https://wpnews.pro/news/where-should-an-ai-agent-s-idempotency-key-come-from", "canonical_source": "https://dev.to/chrissellers/where-should-an-ai-agents-idempotency-key-come-from-4i2f", "published_at": "2026-10-08 18:45:40+00:00", "updated_at": "2026-10-08 18:50:15.375092+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "agent-protocols", "developer-tools"], "entities": ["Agent Middleware", "AMW", "MCP"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/where-should-an-ai-agent-s-idempotency-key-come-from", "markdown": "https://wpnews.pro/news/where-should-an-ai-agent-s-idempotency-key-come-from.md", "text": "https://wpnews.pro/news/where-should-an-ai-agent-s-idempotency-key-come-from.txt", "jsonld": "https://wpnews.pro/news/where-should-an-ai-agent-s-idempotency-key-come-from.jsonld"}}