The Kong recipe

Two ways to put the check in front of your tools from Kong AI Gateway. Every file the recipe applies can be downloaded on its own:

File What
kong-tool-check.yaml route 2: the tool route with its pre-function
pre-function.lua the pre-function itself, as the route carries it
kong-opa.yaml route 1: the OPA plugin's settings
opa-input.example.json the input OPA is asked with
network-policy.yaml the Kubernetes NetworkPolicy that makes Kong the only way to the tools

Both ways put Kong between your agents and your tool servers. The check in route 2 is your own enterprise qls-router, which you run in your own environment at a URL you choose: tool checks and receipts read the calls your agents make, so Stalwart's hosted API (api.stalwart.it) does not serve them. Neither way works unless the tools are reachable through Kong alone, so apply the network policy in network-policy.yaml (with Kong's pods as the gateway), or its equivalent.

1. The rulebook's gate in OPA

Emit your rulebook's gate as a Rego package:

$ RulebookTranslator emit policy.co --target rego --out-dir ./opa
wrote ./opa/rulebook.ir.json
wrote ./opa/Rulebook.rego

The package is stalwart.<the policy file's name> (stalwart.policy here), and its decision is {"decision": "allow" | "refuse" | "clarify" | "silent", "rule": ...} for input.tool under input.context. Load Rulebook.rego into the OPA server Kong's OPA plugin queries, set opa_path in kong-opa.yaml to your package, and apply it. opa-input.example.json shows the input. A refuse is a 403; treat clarify as a call that needs a person, which this route cannot ask for, so refuse it too.

The Rego is the gate and only the gate. The argument schema, argument origins, the session's history and order rules, trust labels, confirmations and undos are not in it, and the emitted file's header says so. Use route 2, or stalwart gateway, for those.

2. A route that calls the full check

kong-tool-check.yaml puts a pre-function on the tool route (pre-function.lua). It reads each MCP tools/call (or a plain {"tool", "arguments"} request); every other MCP message -- initialize, tools/list, a notification -- goes on unchecked, since it runs nothing.

A call from an agent that checks its own calls -- a stalwart-agents agent whose tool source says receipts: true, or a LangGraph agent on our guarded tool node -- carries its receipt: the agent's session history, ending on the check that allowed this call, in the MCP _meta.stalwart or the X-Stalwart-Receipt header. The route sends it to your router's POST /v1/tool-receipts, which says whether the history's token verifies and its last entry allows exactly this call. The call is admitted on the agent's own check, so a write the person approved in the agent is not asked about a second time; a receipt for other arguments, another tool or a call already reported is a 403.

The agent is the one signed into the history's last entry: the route sends no agent of its own, and a receipt cannot be presented as another agent's. A receipt admits one call. The router spends it on the first call it admits, and keeps only digests of the history token and of that call. Presented again, the receipt is a 403 receipt_spent, unless it is that same call -- its arguments and idempotency key unchanged -- and sending it again is safe: a read, a tool declared idempotent, or a write whose key rides in an argument. That is the agent's own retry. A replay of a write under a fresh key is refused.

A call without a receipt is checked in full at your router's POST /v1/tool-checks, and goes through only on allow. confirm comes back to the client as a 409 with the confirmation's wording and token; the client shows them to a person, then sends the call again with the token in X-Stalwart-Confirmation. The session's history travels in X-Stalwart-History, and each answer's entry and token come back in X-Stalwart-Entry and X-Stalwart-History-Token for the client to add to it, so no state is kept in Kong.

Set STALWART_ROUTER_URL (your qls-router's base URL, such as https://qls-router.internal:8080) and QLS_API_KEY in Kong's environment. The route asks /v1/tool-checks and /v1/tool-receipts under it. With STALWART_ROUTER_URL unset, every call is refused with a 503 router_not_configured: the route never falls back to another check.

How the recipe is tested

The package's own suite reads both configurations, and runs pre-function.lua itself in an emulated access phase: MCP calls from an agent with and without a receipt, a tampered receipt, the messages that call nothing, an unreachable check, and a configuration that names no router.