Require human approval for MCP tool calls
Human in the loop, enforced outside the agent: a named tools/call pauses at the MCP gate until a person decides, and the agent has no way to answer for them.
On this page
- What you'll get
- Why the gate is outside the agent
- 1. Route the MCP server through the gate
- 2. Create the approver key
- 3. Name the calls that wait
- 4. Decide what silence means
- 5. Add a signed decision endpoint (optional)
- 6. Run, and answer the prompt
- 7. Check the decisions afterwards
- What this enforces, and what it doesn't
- FAQ
- Can the agent approve its own call?
- Can I approve from my phone or a chat app?
- Can I gate only some arguments, such as payments over a threshold?
- What happens when the approver is away?
Source release coming soon
The source and installers are not public yet, so there is nothing to install today. This guide documents Constle as it behaves now. More in Project status.
What you'll getLink to this section
- Named MCP tool calls held at the gate until a person approves or denies them, at the terminal where
constle runis running or through a signed decision from your own endpoint. - No answer means no call: by default the call is refused and the run stops.
- Every gate opening and decision in the audit log, and signed decisions you can re-verify later against the approver's key.
Why the gate is outside the agentLink to this section
An approval prompt the agent controls is an approval the agent can be talked out of asking for. Constle's gate sits on the only path between the agent and its declared MCP servers, so a gated call cannot reach the server without passing it, and the policy that names the gated tools is read before the sandbox starts. The approver's private key never enters the sandbox, and neither does the address of your decision endpoint. The mechanics: Human gates.
1. Route the MCP server through the gateLink to this section
mcp:
servers:
- id: accounting
url: https://mcp.accounting.internal # host-side only
tools: [list_invoices, pay_invoice]A gate can only hold calls it sees, and it sees calls to servers declared here. With no mcp.servers declared, nothing is gated. The agent reaches this server through CONSTLE_MCP_ACCOUNTING_URL; setting that up is covered in Lock down MCP servers.
2. Create the approver keyLink to this section
constle webhook-keygen approverThis keypair is not an agent identity: it authenticates the person deciding, not the agent asking. Its private key goes to whoever operates your decision endpoint and never to the agent. The command prints a did:key to paste into the Agentfile. human_gates.approver_pubkey is required whenever any call is gated, even if you will only ever approve at the terminal.
3. Name the calls that waitLink to this section
human_gates:
enabled: true # the master switch; the default is false
require_approval_for:
- pay_invoice # the exact MCP tool name
approver_pubkey: did:key:z6Mk… # from `constle webhook-keygen`Entries are exact, case-sensitive tool names: the params.name of a tools/call. There are no patterns and no semantic matching, so pay_invoice gates pay_invoice and nothing else. A call whose name differs from a gated one only in case is refused rather than forwarded ungated. validate and run warn about any entry that provably matches no declared tool, and about every entry when enabled is not true.
4. Decide what silence meansLink to this section
human_gates:
approval_timeout_seconds: 300 # the default
on_timeout: abort # the default: refuse the call and stop the runon_timeout decides every gate that reaches its deadline without a decision, which includes an unreachable or broken decision endpoint. Keep abort. Under proceed, the call is forwarded without approval when time runs out, so the gate becomes a delay.
When constle run is not attached to a terminal (CI, a pipe, a background job), nobody can answer the prompt, so the gate says so once and waits for the deadline. It never treats "nobody is watching" as approval.
5. Add a signed decision endpoint (optional)Link to this section
human_gates:
notify:
- channel: webhook
url_secret_ref: HUMAN_GATE_WEBHOOK_URL # the host variable holding the URLWith approver_pubkey set and this URL resolving, Constle also POSTs each gated request to your endpoint and polls it for a decision, racing the terminal prompt: the first decision wins. A decision must be Ed25519-signed by the approver key over the request id, the decision and the digest of the exact tool call. One that fails any check counts as a denial. Your endpoint can put the request in front of a person however you like, as long as it signs their answer with the approver key. The wire protocol: Human gates, webhook specification.
Without a resolving URL, constle run warns that gated calls will only be decided at the terminal.
6. Run, and answer the promptLink to this section
export HUMAN_GATE_WEBHOOK_URL=https://approvals.example.internal/gates # if you set up step 5
constle run agent.yamlWhen the agent calls pay_invoice, the run pauses:
⏸ human gate: agent "invoice-processor" wants to call MCP tool "pay_invoice" on server "accounting"
subject: sha256:4b3f…e91a
arguments (25 bytes, 3 lines) — shown in full:
{
"invoice_id": "INV-0042"
}
approve? [a]pprove / [d]eny (timeout 300s → abort):Arguments too large to show in full can be denied at the terminal but never approved there. A terminal approval is an unsigned local action, recorded with decided_by: terminal.
7. Check the decisions afterwardsLink to this section
grep gate_ ~/.constle/logs/<agent>-$(date -u +%F).jsonl
constle audit verify --agentfile=agent.yaml ~/.constle/logs/<agent>-$(date -u +%F).jsonlEach gate writes gate_triggered, then gate_approved, gate_denied or gate_timeout. A decision received over the webhook is recorded with its signed fields, and audit verify --agentfile re-verifies it against the Agentfile's approver_pubkey, so an approval the approver never signed cannot sit in the log as though they had. More in Verify what an AI agent did.
What this enforces, and what it doesn'tLink to this section
| Enforced | Not covered |
|---|---|
A gated tools/call reaches its server only after an approval |
Calls the gate never sees: plain HTTPS through allowed_hosts, MCP servers run inside the image, file writes |
| Exact tool-name matching, with case variants of a gated name refused | Rules on arguments: a gate holds every call to the tool, whatever its arguments |
| A decision that arrives is verified, and one that fails any check is a denial | Key rotation or revocation: a compromised approver key stays valid until the Agentfile changes |
on_timeout: abort refuses the call when nobody answers |
One approver per agent; no M-of-N |
| Signed decisions recorded and re-verifiable | A terminal approval signs nothing; the subject digest is unsalted SHA-256, so guessable arguments can be recovered from the log |
Gates match tool names only: limitation 1. The full list of limits of the signed channel is on Human gates.
FAQLink to this section
Can the agent approve its own call?Link to this section
Not through Constle. The terminal prompt is on the host where constle run runs, and a webhook decision has to be signed with the approver's private key, which never enters the sandbox. The webhook URL is read from a host variable and is not passed to the agent either.
Can I approve from my phone or a chat app?Link to this section
Yes, if your decision endpoint does it. Constle POSTs the gated request to your endpoint and polls for a decision; what happens in between is up to you, provided the answer comes back signed with the approver key. Constle does not ship such an endpoint.
Can I gate only some arguments, such as payments over a threshold?Link to this section
No. The gate matches the tool name, which is the only identifier it sees at the protocol level, so every call to a gated tool waits. If you need a threshold, have the MCP server expose two tools, enforce the limit itself on the one for small amounts, and gate the other.
What happens when the approver is away?Link to this section
With the defaults, the call waits 300 seconds, is then refused, and the run stops with a gate_timeout event. Nothing is forwarded without a decision unless you chose on_timeout: proceed.