Your agent calls a payout tool. The payout goes through on the other side, but the response never makes it back. The client times out, and the agent does the reasonable thing: it retries. Now the vendor has been paid twice.
Swap "payout" for "delete the staging database" or "send the customer email" and the story is the same. The problem is that nothing between the agent and the tool knows the second call is the same action as the first.
This post walks through a pattern for that gap, first in general terms you can build yourself, then how Agent Middleware (AMW), the project I work on, applies it. The Python below is a simplified sketch I wrote for this post, not AMW's source. The one AMW snippet is the request shape from its README.
A permit is a signed, scoped grant that says what an agent may do: which tools, which scopes, how much it may spend, and until when. The gateway checks it before anything costs money or touches the outside world.
A receipt is a signed record of what happened to one governed call: which permit it ran under, which tool, what was charged, and the outcome. A refusal under a valid permit gets one too, so "denied" leaves evidence just like "succeeded".
You can build this in front of any tool that has side effects. The flow:
The ordering matters. The replay check comes before the permit check, the charge, and the dispatch, so a retry never gets a chance to spend anything.
This is illustrative code to show the shape of the idea. It is not AMW's implementation, and the store, permits, tools and sign_receipt pieces are stand ins for whatever your stack uses.
import hashlib
import json
class KeyReused(Exception):
pass
class InProgress(Exception):
pass
def request_hash(tool, args, caller, permit_id):
canonical = json.dumps(
{"tool": tool, "args": args, "caller": caller, "permit": permit_id},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode()).hexdigest()
def governed_call(store, permits, tools, *, key, tool, args, caller, permit_id):
h = request_hash(tool, args, caller, permit_id)
record = store.get(caller, key)
if record is not None:
if record.request_hash != h:
raise KeyReused(key) # same key, different request
if record.response is None:
raise InProgress(key) # first call still running
return record.response # original result and receipt
record = store.insert(caller, key, h)
permit = permits.check(permit_id, caller=caller, tool=tool) # raises on deny
permit.reserve(tools[tool].price)
result = tools[tool].call(args, idempotency_key=key)
response = {"result": result, "receipt": sign_receipt(permit, tool, h, result)}
store.finish(record, response)
return response
A real version also needs a signed receipt for denials, a budget release when the call fails before it is sent, and an answer for the case where the tool may have acted but you never heard back. That last one is where most of the difficulty lives.
In AMW, agents act under a permit; a same-key retry returns the original receipt, no second call or charge.
A governed call is an MCP tools/call with the wallet, permit and idempotency key carried in mcpContext. This is the request shape from the AMW README:
curl -sS -X POST "$API_URL/mcp/messages" \
-H "X-API-Key: $AGENT_KEY" \
-H "Content-Type: application/json" \
-d "{
\"jsonrpc\": \"2.0\",
\"id\": \"request-1\",
\"method\": \"tools/call\",
\"params\": {
\"name\": \"$TOOL_ID\",
\"arguments\": {\"input\": \"hello\"},
\"mcpContext\": {
\"wallet_id\": \"$WALLET_ID\",
\"permit_id\": \"$PERMIT_ID\",
\"idempotency_key\": \"invoke-1\"
}
}
}"
The behavior follows the same steps as the sketch:
idempotency_key_reused, and no new receipt. A retry while the first call is still running gets idempotency_in_progress.
On the permit side, each governed call is checked against a signed permit: which wallet and key may use it, which tools and scopes it covers, a credit budget, an expiry, and optional limits such as per tool call counts, an aggregate value cap, forbidden argument fields, and a required recipient domain. Calls outside the permit are refused before any charge.
Every governed call, whether it succeeds, is denied, or fails, produces an Ed25519 signed receipt. The receipt binds the permit, wallet, tool, credits authorized and charged, outcome, ledger entry and audit event, plus SHA-256 hashes of the request and the tool's response. A receipt can be exported as a portable bundle and checked by anyone, with no account and no call back to AMW, using the published public key.
delivery_uncertain (the gateway sent it, but cannot say what the tool did) with a signed receipt. AMW never re-sends the call automatically. Someone still has to reconcile it with the tool.
I'm Chris, the founder and sole owner of AMW. It is pre-revenue. This post was drafted with help from an AI assistant; the Python sketches are illustrative and are not AMW's source, the curl call is from AMW's README, and every product claim was checked against the AMW code. A sample signed receipt and the public API docs are linked from thisisatest.tech.
The pattern above works without AMW. The hard part in my experience is step 1, so here is my question: when your agent retries a tool call, where does its idempotency key come from, and does it survive a process restart?