{"slug": "gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt", "title": "Gate a risky AI agent tool call with a permit, and keep a receipt", "summary": "A developer working on Agent Middleware (AMW) outlined a permit-and-receipt pattern for gating risky AI agent tool calls, in which a signed, scoped permit authorizes a call and a signed receipt records its outcome. The design places an idempotency-key replay check before the permit check, charge, and dispatch so that a retried call returns the original result instead of executing a second time, addressing duplicate payouts, database deletions, and emails caused by timeouts. In AMW, a governed call is an MCP tools/call carrying the wallet, permit, and idempotency key in mcpContext.", "body_md": "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.\n\nSwap \"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.\n\nThis 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.\n\nA **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.\n\nA **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\".\n\nYou can build this in front of any tool that has side effects. The flow:\n\nThe 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.\n\nThis 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.\n\n``` python\nimport hashlib\nimport json\n\nclass KeyReused(Exception):\n    pass\n\nclass InProgress(Exception):\n    pass\n\ndef request_hash(tool, args, caller, permit_id):\n    canonical = json.dumps(\n        {\"tool\": tool, \"args\": args, \"caller\": caller, \"permit\": permit_id},\n        sort_keys=True,\n        separators=(\",\", \":\"),\n    )\n    return hashlib.sha256(canonical.encode()).hexdigest()\n\ndef governed_call(store, permits, tools, *, key, tool, args, caller, permit_id):\n    h = request_hash(tool, args, caller, permit_id)\n\n    # Look up the key before anything can spend money or touch the tool.\n    record = store.get(caller, key)\n    if record is not None:\n        if record.request_hash != h:\n            raise KeyReused(key)        # same key, different request\n        if record.response is None:\n            raise InProgress(key)       # first call still running\n        return record.response          # original result and receipt\n\n    # New key only. The store must enforce uniqueness on (caller, key),\n    # so two racing first attempts cannot both get past this line.\n    record = store.insert(caller, key, h)\n    permit = permits.check(permit_id, caller=caller, tool=tool)  # raises on deny\n    permit.reserve(tools[tool].price)\n    result = tools[tool].call(args, idempotency_key=key)\n    response = {\"result\": result, \"receipt\": sign_receipt(permit, tool, h, result)}\n    store.finish(record, response)\n    return response\n```\n\nA 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.\n\nIn AMW, agents act under a permit; a same-key retry returns the original receipt, no second call or charge.\n\nA 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:\n\n```\ncurl -sS -X POST \"$API_URL/mcp/messages\" \\\n  -H \"X-API-Key: $AGENT_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"{\n    \\\"jsonrpc\\\": \\\"2.0\\\",\n    \\\"id\\\": \\\"request-1\\\",\n    \\\"method\\\": \\\"tools/call\\\",\n    \\\"params\\\": {\n      \\\"name\\\": \\\"$TOOL_ID\\\",\n      \\\"arguments\\\": {\\\"input\\\": \\\"hello\\\"},\n      \\\"mcpContext\\\": {\n        \\\"wallet_id\\\": \\\"$WALLET_ID\\\",\n        \\\"permit_id\\\": \\\"$PERMIT_ID\\\",\n        \\\"idempotency_key\\\": \\\"invoke-1\\\"\n      }\n    }\n  }\"\n```\n\nThe behavior follows the same steps as the sketch:\n\n`idempotency_key_reused`, and no new receipt. A retry while the first call is still running gets `idempotency_in_progress`.\nOn 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.\n\nEvery 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.\n\n`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.\nI'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](https://www.thisisatest.tech).\n\nThe 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?**", "url": "https://wpnews.pro/news/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt", "canonical_source": "https://dev.to/chrissellers/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt-47a7", "published_at": "2026-10-08 03:00:32+00:00", "updated_at": "2026-10-08 03:17:32.837991+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-safety", "developer-tools"], "entities": ["Agent Middleware", "AMW", "MCP"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt", "markdown": "https://wpnews.pro/news/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt.md", "text": "https://wpnews.pro/news/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt.txt", "jsonld": "https://wpnews.pro/news/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt.jsonld"}}