constle docs constle docs pre-1.0

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

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 run is 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

agent.yamlyaml
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

shell
constle webhook-keygen approver

This 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

agent.yamlyaml
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

yaml
human_gates:
  approval_timeout_seconds: 300   # the default
  on_timeout: abort               # the default: refuse the call and stop the run

on_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

yaml
human_gates:
  notify:
    - channel: webhook
      url_secret_ref: HUMAN_GATE_WEBHOOK_URL   # the host variable holding the URL

With 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

shell
export HUMAN_GATE_WEBHOOK_URL=https://approvals.example.internal/gates   # if you set up step 5
constle run agent.yaml

When the agent calls pay_invoice, the run pauses:

Output
⏸  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

shell
grep gate_ ~/.constle/logs/<agent>-$(date -u +%F).jsonl
constle audit verify --agentfile=agent.yaml ~/.constle/logs/<agent>-$(date -u +%F).jsonl

Each 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.