Gate a risky AI agent tool call with a permit, and keep a receipt
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 twi
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.
Two terms first
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".
The general pattern
You can build this in front of any tool that has side effects. The flow:
- The agent picks an idempotency key per logical action, before the first attempt, and keeps it across retries. Not per HTTP request. Per "pay invoice 4417".
- The gateway hashes the request. Tool name, arguments, who is calling, and under which permit.
- The gateway looks up the key. If it already exists with the same hash and a finished result, return the stored response and receipt. Do not call the tool. Do not charge.
- If the key exists with a different hash, refuse. A reused key with a new payload is a bug or an attack, not a retry.
- If the key exists and the first call is still running, say so. Return "in progress" rather than starting a second call.
- Only for a new key: check the permit, reserve budget, call the tool once, and store the response and receipt under that key.
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.
A simplified sketch
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)
# Look up the key before anything can spend money or touch the tool.
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
# New key only. The store must enforce uniqueness on (caller, key),
# so two racing first attempts cannot both get past this line.
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.
How AMW applies it
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:
- Governed calls require a key. Retry safety is not opt in.
- The request hash covers the tool name, the arguments, the wallet and the permit. The key is scoped per wallet and backed by a database uniqueness constraint.
-
Reusing a key with a different payload fails closed with
idempotency_key_reused, and no new receipt. A retry while the first call is still running getsidempotency_in_progress. - A replay returns the stored response before permit validation, the charge, or the dispatch happen. AMW's own test suite sends the same governed call twice and asserts the same receipt ID and a single ledger debit.
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.
What the receipt binds
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.
Limits
- A new key is a new call. A retry sent under a new idempotency key is treated as a new call, not a repeat, and it does not get the original receipt back. If the permit sets a per tool call limit and that limit is used up, the retry is refused. Do not count on anything else to catch it. An agent that restarts and mints a fresh key for the same action can still pay twice. Keep the key with the task, not the process.
- Not exactly once at the remote tool. AMW makes each accepted key produce at most one call to the tool, one charge and one receipt. Whether the remote side effect itself happens only once depends on the tool honoring the forwarded idempotency key.
-
Lost responses stay uncertain. For remote tools, AMW records a durable checkpoint right before it calls the tool. If it crashes before that point, the charge is refunded and the budget released. If it crashes or loses the connection after that point, the call is kept as charged and marked
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. - Receipts sign hashes, not content. They do not prove the tool's output was correct, and the signing key is a single injected key with no KMS.
- Narrow scope today. MCP is the only governed adapter, upstream execution is limited to one configured tool per deployment, and the supported deployment is vendor managed and single tenant. I am not promising dates for anything beyond that.
Disclosure
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?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.