Idempotency Keys Don't Stop AI Agents From Doing Things Twice
Your duplicate check is keyed to the request. Agent duplicates are new requests with the same intent. Here's the key that catches them.

Here's a refund tool that most teams would call safe:
def issue_refund(order_id: str, amount_cents: int, note: str):
key = sha256(f"{order_id}|{amount_cents}|{note}".encode()).hexdigest()
return payments.refund(order_id, amount_cents, idempotency_key=key)
Same arguments, same key, so a retry can't refund twice. Now hand it to an AI agent.
The provider is slow, the call times out, and the model tries again. First attempt's note: "Refund for damaged item". Second attempt's note: "Customer refund: item arrived damaged". New hash, new key, and if the first refund actually completed after the timeout, the customer gets two.
The code isn't wrong for the caller it was designed for. It's wrong for this one.
A retry resends a request. An agent re-makes a decision. Your duplicate check has to survive the difference.
Why request-based keys assume a boring caller
Classic idempotency keys work because the client generates the key once and resends it unchanged. Stripe's docs spell out the contract: the server saves the result for a key, a repeat gets the saved response, a reused key with different parameters gets an error, and keys can be pruned once they're at least 24 hours old.
An agent loop breaks the "resends it unchanged" part. The model writes a tool call, your code executes it, the result is appended, and the model decides what's next (here's the full tool-calling loop). Every retry is a new generation: a new tool-call ID and freshly written arguments.
The five ways an agent does something twice

| # | How the second call happens | Tool-call ID key | Argument-hash key | Effect key |
|---|---|---|---|---|
| 1 | Transport retry (same bytes) | Caught | Caught | Caught |
| 2 | Model retries after timeout or error | Missed | Caught if wording matches | Caught |
| 3 | Framework replays a step after resume | Depends | Depends | Caught |
| 4 | Receipt trimmed or summarized out of context | Missed | Missed | Caught |
| 5 | User asks again in a new session | Missed | Missed | Caught (with a durable ledger) |
A few notes on the rungs that surprise people:
- Rung 2 is designed in. The MCP spec says clients should implement timeouts for tool calls. Timeouts guarantee a class of calls whose outcome is unknown. (More on that in Timeouts Don't Cancel Work)
- Rung 3 is documented. LangGraph's interrupt docs state that on resume the runtime restarts the entire node from the beginning, and recommend that side effects before an interrupt be idempotent, moved after it, or put in a separate node.
- Rung 4 is a memory problem, not a retry problem. Summaries keep the narrative and drop state changes like "refund issued", the same pattern as AI summaries deleting the exception. The model sees an open complaint and resolves it again, in new words.
What about MCP's idempotentHint?
The annotation means repeated calls with the same arguments have no additional effect. That covers rungs 1–2. Rungs 4–5 have different arguments by construction. The spec also says clients must treat annotations as untrusted unless they come from a trusted server. Useful documentation, not enforcement.
The fix: key the effect, not the call
Ask "has this already happened in the world?" instead of "have I seen this request?" The answer has a name, the effect key: the identity of the thing that must happen at most once.
| Tool | Request-based key (fragile) | Effect key |
|---|---|---|
issue_refund |
hash of args / call ID | refund:return_req_77 |
send_welcome_email |
hash of subject + body | welcome_email:user_311 |
schedule_followup |
call ID | followup:ticket_5521:day3 |
Rules:
- Build it in code from stable business identifiers.
- Never include model-written free text (notes, reasons, bodies).
- Never ask the model to generate it. A model inventing a key on attempt two invents a new key.
def issue_refund(return_req_id: str, amount_cents: int, note: str):
key = f"refund:{return_req_id}" # effect, not call
prior = ledger.get(key)
if prior and prior.status == "done": # duplicate: return the receipt
return ok(status="already_done", refund_id=prior.refund_id,
amount_cents=prior.amount_cents, at=prior.at)
if prior and prior.status == "pending": # unknown: look it up, don't re-fire
return settle_unknown(key)
ledger.put(key, status="pending", amount_cents=amount_cents) # record intent first
r = payments.refund(return_req_id, amount_cents, idempotency_key=key)
ledger.put(key, status="done", refund_id=r.id, at=r.created)
return ok(status="done", refund_id=r.id, amount_cents=amount_cents, at=r.created)
Why each line is there:
ledgerlives in the tool, not the transcript. Context trimming can't delete it.pendingis written first. The refund and the ledger row are two writes with no safe order; intent-first leaves a reconcilable record if you crash mid-call.- The same key goes downstream. It covers the pending window. And since Stripe errors on a reused key with different parameters, an attempt that drifted from $40 to $45 is rejected at the boundary instead of becoming a second, different refund.
Return "already done" as a success, not an error

If the duplicate comes back as isError: true, the model reads an obstacle between it and the user's goal. It may retry, rephrase, or reach for another tool (store credit, say) that gets the user what they asked for. Your block held; the agent walked around it.
Return a normal result instead:
{
"status": "already_done",
"refund_id": "re_1042",
"amount_cents": 4000,
"at": "14:02"
}
The model now reads a fact and reports it. Block in code; describe the world, not the refusal.
For timeouts, return a third state, pending, with an instruction the model can act on ("may already be processing; don't issue another; check get_refund_status") and give it that read-only tool, keyed by the same effect key.
When a repeat is legitimate
Effect keys block real repeats too. A second returned item from order 8812 deserves a second refund, which is why the examples above key on return_req_77, not order_8812.
- The discriminator must be a business record. A legitimate second refund has a second return request. A duplicate only has new wording.
- No record, no repeat. If nothing distinguishes the second action, a human or workflow step creates that record first. Not the model's confidence.
- Key lifetime matches how long people come back. Provider key stores may forget after a day; your ledger should last as long as someone might ask "did it go through?"
- Prefer converging tools.
ensure_refunded(return_req_77)is safe to repeat by design.send_emailisn't, so it needs a key.
If you can't name a tool's effect key, the tool isn't ready to hand to an agent.

The Rephrase Test (one test per rung)
Run this against every tool that changes something outside the conversation.
def test_refund_happens_once(fake_payments, ledger):
base = dict(return_req_id="rr_77", amount_cents=4000)
r1 = call_tool("issue_refund", call_id="c1", note="Refund for damaged item", **base)
r2 = call_tool("issue_refund", call_id="c1", note="Refund for damaged item", **base) # rung 1
r3 = call_tool("issue_refund", call_id="c2", note="Refund for damaged item", **base) # rung 2
r4 = call_tool("issue_refund", call_id="c3",
note="Customer refund: item arrived damaged", **base) # rung 4
r5 = call_tool("issue_refund", call_id="c9", session="next-day",
note="Re-issuing refund per customer", **base) # rung 5
assert fake_payments.refund_count("rr_77") == 1
for r in (r2, r3, r4, r5):
assert not r.is_error
assert r.data["refund_id"] == r1.data["refund_id"]
def test_timeout_after_success(fake_payments):
fake_payments.complete_then_hang(seconds=60) # does the work, never answers
first = call_tool("issue_refund", call_id="c1", return_req_id="rr_78",
amount_cents=4000, note="x")
assert first.data["status"] == "pending" # unknown, not failed
again = call_tool("issue_refund", call_id="c2", return_req_id="rr_78",
amount_cents=4000, note="y")
assert fake_payments.refund_count("rr_78") == 1
Pass = one effect in the world, and every later call returns the first receipt as a success. The timeout-after-success case is the one teams skip, because it needs a fake that succeeds and lies about it. It's also the one production produces on a slow afternoon.
Key Takeaways
- Agents don't retry requests; they re-make decisions, with new call IDs and new wording.
- Tool-call IDs catch only transport retries. Argument hashes break the moment the model rephrases.
- Key the effect (
refund:return_req_77), built in code from business identifiers, never from model text. - Keep a ledger in the tool layer; the model's memory of the chat is a hint, not a record.
- Return "already done" as a success with the receipt. Errors invite workarounds.
- Treat timeouts as
pending, and check status by effect key before acting again.
FAQ
What is idempotency for AI agent tool calls?
It means a tool that changes something (a refund, an email, a record) has the same effect whether the agent calls it once or many times. For agents, that has to hold even when the repeated calls have different IDs, different wording, or come from a different session.
Why do AI agents call the same tool twice?
Five common reasons: a transport retry, the model retrying after a timeout or error, a framework replaying a step after a resume, the earlier result being trimmed or summarized out of context, and the user asking again in a new session.
Why doesn't an idempotency key based on the tool-call ID work?
Each time the model emits a tool call it gets a new ID. That key only catches retries of the exact same call, which is the rarest kind of agent duplicate.
What is an effect key?
The identity of the action that must happen at most once, built by your code from stable business identifiers, such as refund:return_req_77. It deliberately excludes model-written text, because that text changes between attempts.
Should a blocked duplicate tool call return an error?
Usually not. An error reads to the model as an obstacle, and it may retry or route around it with another tool. Return a normal result with status already_done and the original receipt so the model reports the fact instead.
Does MCP's idempotentHint make a tool safe for agents?
No. It's a hint that repeated calls with the same arguments have no extra effect, and the MCP spec says clients must treat annotations as untrusted unless the server is trusted. Agent duplicates often have different arguments, so enforce idempotency in the tool itself.
Keep reading
- Why Your API Charges People Twice (And the One Idea That Fixes It): the classic idempotency key, before agents.
- How AI Learned to Actually Do Things: Tool Calling and MCP Explained: the loop this whole article lives inside.
- Everyone's Building AI Agents. Almost No One Will Trust Them.: why autonomy gets earned one tool at a time.
- New here? Start Here: A Plain-English Map to Understanding AI
The bottom line
Request-based idempotency trusted the caller to remember what it sent. An agent is a caller with excellent manners and no reliable memory of what it did ten minutes ago. Stop asking it to remember. Make the tool remember.
Block the duplicate in code. Then describe the world, not the refusal.
Get one of these in your inbox each week: subscribe to Simply Explained.
Adam Jaber is a software engineer who writes Simply Explained: complex topics, made simple. No jargon, no hype.
About the author
- I'm a software engineer who spends most days building systems that solve real problems. When I'm not shipping code, I'm either untangling a tricky problem or writing about what I learned doing it. Currently exploring AI on the side.
Comments
Join the discussionNo comments yet. Be the first to comment.





