Guide
Approvals and the kill switch
AgentFox can hold a single call until a person approves it, tell you which conversations should have reached a person and did not, and stop an agent outright while you find out what it did.
When to use this
- Approvals: a tool is fine to call sometimes but not unattended, such as
billing.exportoremail.sendto a customer, or an argument came from somewhere you do not trust. - Escalation policy: a support agent talks to people, and some of those conversations (an explicit request for a human, repeated failure, anger, a legal threat) should go to a person.
- Quarantine and kill: an agent is doing something you did not expect and you need it to stop now, reversibly, with a record of who did it.
Where an approval comes from
A decision with the verdict escalate files an approval request and returns its id. In practice that happens three ways:
| Cause | Rule in rules_fired | How you set it up |
|---|---|---|
| The grant says a person approves every call. | capability.approval_required | agentfox permit grant <agent> <tool> --requires-approval |
An argument came from somewhere worse than the grant's --max-taint (a retrieved document, another tool's output). | capability.approval_required, with a reason naming the argument and where it came from | Any grant. The default --max-taint is user; see Contain tool calls. |
A policy rule with effect: escalate matched, in enforce mode. | the rule's own id | Shipped packs (several rules in tool-containment) or your own; see Policy language. |
In observe mode an escalating rule is recorded as what would have happened and no approval is needed for the call to go through.
Worked example: a held export
This uses the gateway from Any language: the gateway and the operators that agentfox admin seed creates (deciding an approval needs the owner, admin or security role; marcus@example.com is security).
Require a person for the tool
bash agentfox declare tool billing.export --impact high_impact --description "Export invoices for an account" agentfox permit grant support-triage billing.export --requires-approval --yesOutput billing.export declared — impact high_impact, output untrusted … Grant billing.export to support-triage agent:support-triage actions * argument limits — max provenance user arguments the user typed, nothing retrieved human approval required for every call expires never granted cap_01m469qwr2pjeckkc3 agent:support-triageAsk before running it
bash agentfox serve & curl -s http://localhost:8080/v1/guard/tool_call \ -H 'Content-Type: application/json' \ -d '{"agent":"support-triage","tool":"billing.export", "arguments":{"account":"acme","period":"2026-09"}, "provenance":{"account":"user","period":"user"}, "intent":"export September invoices for acme"}'Output { "verdict": "escalate", "applied_verdict": "escalate", "effective_verdict": "escalate", "mode": "enforce", "approval_id": "apr_01m46jnb2j2zszsbsm", "reason": "The granting capability requires human approval for this action.", "trace_id": "trc_01m46jnb1tvwstznav", "decision_id": "dec_01m46jnb2fj3mscvm0", "rules_fired": [{"rule_id": "capability.approval_required", "effect": "escalate", …}], … }Through the proxy (
/v1/chat/completions,/v1/messages) an escalation is an HTTP 428 with{"error":{"type":"agentfox_approval_required","approval_id","poll",…}}and the model is not called. The OpenAI and Anthropic SDKs raise it asAPIStatusError.Look at the queue
bash export AGENTFOX_API_TOKEN=nom_api_… # agentfox admin auth issue marcus@example.com curl -s "http://localhost:8080/api/approvals?status=pending" \ -H "Authorization: Bearer $AGENTFOX_API_TOKEN"Output { "approvals": [ { "id": "apr_01m46jnb2j2zszsbsm", "agent_id": "agt_01m469q1nfsd4b4c3h", "tool": "billing.export", "arguments": {"account": "acme", "period": "2026-09"}, "reason": "The granting capability requires human approval for this action.", "status": "pending", "requested_at": "2026-10-05T17:44:22.866282+00:00", "expires_at": "2026-10-05T18:14:22.865869+00:00", "trace_id": "trc_01m46jnb1tvwstznav", "decision_id": "dec_01m46jnb2fj3mscvm0", "timeout_action": "deny" } ] }statusfilters bypending,approved,denied,expiredorused. An approval held over a message rather than a tool call hastool: "message:input"(ormessage:output,message:agent_message) and the message inarguments.content, with personal data masked. From the CLI:bash agentfox permit approvals list agentfox permit approvals show apr_01m46jnb2j2zszsbsmIn the web app Approvals: pending requests, with Approve and Deny
Decide it
bash curl -s -X POST http://localhost:8080/api/approvals/apr_01m46jnb2j2zszsbsm/approve \ -H "Authorization: Bearer $AGENTFOX_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"rationale":"Finance asked for the September export (ticket 4411)."}' curl -s http://localhost:8080/api/approvals/apr_01m46jnb2j2zszsbsm \ -H "Authorization: Bearer $AGENTFOX_API_TOKEN"Output {"id":"apr_01m46jnb2j2zszsbsm","status":"approved","resolver":"marcus@example.com"} {"id":"apr_01m46jnb2j2zszsbsm","status":"approved","reason":"The granting capability requires human approval for this action.","tool":"billing.export","arguments":{"account":"acme","period":"2026-09"},"rationale":"Finance asked for the September export (ticket 4411).","agent_id":"agt_01m469q1nfsd4b4c3h","expires_at":"2026-10-05T18:16:02.114023+00:00","trace_id":"trc_01m46jnb1tvwstznav"}…/denytakes the same body. Both are written to the audit chain with the person who decided. A user without the role is refused:Output {"detail":"role 'developer' may not modify 'approvals'. Permitted: ['admin', 'owner', 'security']."}Or from the CLI, against the same database:
bash agentfox permit approvals approve apr_01m46jnb2j2zszsbsm -r "Finance asked for the September export (ticket 4411)." --as marcus@example.comRun it
Approving does not run anything. The agent sends the same call again with the approval id, and that call runs:
bash curl -s http://localhost:8080/v1/guard/tool_call -H 'Content-Type: application/json' -d '{"agent":"support-triage","tool":"billing.export", "arguments":{"account":"acme","period":"2026-09"}, "provenance":{"account":"user","period":"user"}, "intent":"export September invoices for acme", "approval_id":"apr_01m46jnb2j2zszsbsm"}'Output {"verdict": "allow", "rules_fired": [{"rule_id": "capability.approval_required", …}, {"rule_id": "approval.redeemed", "effect": "allow", …}], …}An approval lets exactly one call through: the same agent, the same tool and the same arguments the person saw, within 30 minutes of the approval. Sending it again, or with different arguments, escalates as before and files a new approval; the reason says why the approval presented was not used. It never turns a block into an allow. Through the proxy, send the same request with the header
X-AgentFox-Approval: apr_….
Nobody answering is a denial. An approval expires 30 minutes after it was filed and its timeout_action is deny, so polling it after that returns expired. Once approved, it can be redeemed for 30 minutes; once redeemed it reads used.
In Python: ApprovalRequired
The SDK (agentfox.frameworks.sdk.AgentFox) raises ApprovalRequired on an escalate verdict and PolicyViolation on a block. The exception carries approval_id and trace_id. fox.wait_for_approval(id, timeout) waits for the decision and returns approved, denied, expired, or pending if the timeout ran out; guard_tool(…, approval_id=id) is the retry that runs. This one talks to the gateway (base_url) with the agent's own key; without base_url the same code checks in-process against the local database.
import os
from agentfox.frameworks.sdk import AgentFox, ApprovalRequired, PolicyViolation
fox = AgentFox(
agent="support-triage",
base_url="http://localhost:8080",
api_key=os.environ["AGENTFOX_AGENT_KEY"], # nom_agt_…, the agent's own key
)
def export_invoices(account: str, period: str) -> None:
arguments = {"account": account, "period": period}
with fox.session(intent=f"export {period} invoices for {account}") as s:
try:
s.guard_tool("billing.export", arguments)
except ApprovalRequired as held:
print(f"held for a person: {held.approval_id}")
status = fox.wait_for_approval(held.approval_id, timeout=1800)
print(f"decision: {status}")
if status != "approved":
return
# The retry: the same call, presenting the approval. Runs once.
s.guard_tool("billing.export", arguments, approval_id=held.approval_id)
except PolicyViolation as refused:
print(f"refused: {refused}")
return
print(f"exporting {period} for {account}") # run the real export here
export_invoices("acme", "2026-09")Run it, and approve from another terminal while it waits:
held for a person: apr_01m469wyjmfgyk34yr
decision: approved
exporting 2026-09 for acmeDenied instead:
held for a person: apr_01m469xcaf6c5vkj7s
decision: deniedWith agentfox.auto() the model's tool call is withheld and agentfox.Blocked is raised, with the approval id on exc.result.approval_id:
agentfox: tool call billing.export needs human approval (approval apr_01m469ya25jgwswme8) by capability.approval_required: The granting capability requires human approval for this action. No argument came from untrusted content.The response that asked for the call is not handed back, so after an approval your code makes the call itself, through guard_tool(…, approval_id=…) as above.
Conversations that should reach a person
Approvals hold one call. Escalation policy is about whole conversations: when a user asks for a human, gets angry, keeps failing, or raises a legal or medical topic, the conversation should be handed off. AgentFox checks this after the fact, over the turns it recorded, and reports the conversations that qualified and never got one.
Declare the policy
bash agentfox declare escalation --agent support-triage --turn-depth 6 --repeated-failure 2 --sla-minutes 30 --owner supportOutput ✓ escalation policy for support-triage owner support · SLA 30 min · observe mode conditions: confidence_below, explicit_request, regulated_topics, repeated_abstention, repeated_failure, sentiment_below, turn_depth observe: nothing is handed off for you; the hourly scan records missed escalations as findings. --mode enforce queues hand-offs.Omit
--agentto set the default for every agent. Conditions you do not name keep their defaults: an explicit request always qualifies, sentiment at or below -0.6, regulated topics (legal, medical, financial advice, complaint, discrimination), confidence below 0.35, two abstentions, eight turns.--sla-minutesis how long a hand-off may wait before it is breached.--modedecides what AgentFox does about it. Inobserve(the default) nothing is queued for you: the scheduledescalation.scanjob (hourly, over the last 24 hours) raises amissed_escalationfinding for each conversation that qualified. Inenforce, a conversation is handed off on the turn that first qualifies, and the same job queues a retroactive hand-off for any it finds. Scheduled jobs run when the cron calls/api/internal/jobs/run.Record conversations
Turns are recorded by
agentfox.auto(), and by the gateway proxy for any request with anX-AgentFox-Sessionheader. Three turns of one conversation and one of another:bash for msg in "My invoice for September is wrong." \ "It is still wrong, the total doubled." \ "This is ridiculous. I want to speak to a manager."; do curl -s -o /dev/null http://localhost:8080/v1/chat/completions \ -H 'Content-Type: application/json' -H 'X-AgentFox-Agent: support-triage' \ -H 'X-AgentFox-Session: conv-1001' \ -d "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"$msg\"}]}" doneFind the ones that were missed
bash agentfox report escalations --hours 24Output 5 conversation(s) · 2 qualified for escalation · 1 missed (50.0%, target < 5%) conv-1001 3 turns · qualified at turn 2 · explicit_request, sentiment Read-only. Re-run with --apply to raise findings and retroactive hand-offs so the people still waiting are actually queued. The scheduled escalation.scan job does this hourly where the policy's mode is enforce.Turns are counted from 0, so "turn 2" is the third message. The report is read-only.
--applyraises amissed_escalationfinding and puts a hand-off in the queue for each missed conversation:bash agentfox report escalations --hours 24 --apply agentfox report escalationsOutput 5 conversation(s) · 2 qualified for escalation · 1 missed (50.0%, target < 5%) conv-1001 3 turns · qualified at turn 2 · explicit_request, sentiment 5 conversation(s) · 2 qualified for escalation · 0 missed (0.0%, target < 5%)Work the hand-off queue
bash curl -s "http://localhost:8080/api/escalation/handoffs?agent=support-triage&status=pending" \ -H "Authorization: Bearer $AGENTFOX_API_TOKEN"Output { "handoffs": [ { "id": "hnd_01m469yz15b43n8fc7", "session_id": "conv-1001", "agent_slug": "support-triage", "status": "pending", "reason": "the user asked for a human; sentiment -0.90 at or below -0.6", "summary": "[0] My invoice for September is wrong. → [1] It is still wrong, the total doubled. → [2] This is ridiculous. I want to speak to a manager.", "triggers": [ {"condition": "explicit_request", "detail": "the user asked for a human", "turn_index": 2, "severity": "high"}, {"condition": "sentiment", "detail": "sentiment -0.90 at or below -0.6", "turn_index": 2, "severity": "high"} ], "owner_role": "support", "due_at": "2026-10-05T15:42:21.029445", "completeness": {"score": 1.0, "missing": [], "complete": true, …}, "detected_retroactively": true, … } ] }Whoever picks it up acknowledges it:
bash curl -s -X POST http://localhost:8080/api/escalation/handoffs/hnd_01m469yz15b43n8fc7/acknowledge \ -H "Authorization: Bearer $AGENTFOX_API_TOKEN"A pending hand-off past its
due_atis marked breached and raises ahandoff_sla_breachfinding whenever the scan acts: the hourlyescalation.scanjob,report escalations --apply, orPOST /api/escalation/scan.In the web app Approvals → Escalation: missed escalations and the hand-off queue
Telling AgentFox your agent did hand off
If your agent hands a conversation to a person itself, record that turn with escalated: true, or it will be reported as missed:
curl -s -X POST http://localhost:8080/api/escalation/turns \
-H "Authorization: Bearer $AGENTFOX_API_TOKEN" -H 'Content-Type: application/json' \
-d '{"session_id":"conv-1003","agent":"support-triage",
"user_text":"Can I talk to a real person about my invoice?",
"agent_text":"Connecting you to the billing team now.","escalated":true}'
agentfox report escalations{"id":"trn_01m469zc5ytngmyd5p","turn_index":0,"signals":{"sentiment":0.0,"flags":[],"explicit_request":true,"topics":[],"abstained":false,"claims_resolution":false,"failed":false}}
6 conversation(s) · 3 qualified for escalation · 0 missed (0.0%, target < 5%)Quarantine, kill and resume
Two stop states. quarantined means "stop while we investigate"; killed means "stop now". They refuse the same calls and differ in what they declare, which an incident review will ask about. Both are reversible with resume, and every change is on the audit chain.
agentfox agents quarantine support-triage --reason "INC-212: exporting invoices for accounts nobody asked about"
agentfox agents list --stoppedsupport-triage active → quarantined INC-212: exporting invoices for accounts
nobody asked about
agent state reason by when
support-triage quarantined INC-212: exporting cli 2026-10-05T15:12:55
invoices for accounts
nobody asked aboutThe next model call through the proxy is refused before it reaches the model:
HTTP/1.1 403 Forbidden
x-agentfox-verdict: block
x-agentfox-mode: enforce
{"type": "agentfox_policy_violation", "message": "Agent is quarantined: INC-212: exporting invoices for accounts nobody asked about (by cli)", "verdict": "block", "trace_id": "trc_01m46a06mnmr02yhr7"} ['agent.quarantined']What a stopped agent can and cannot still do
The state is read at the start of each call. A call that already passed the check finishes; the next one is refused. Not every surface reads it:
| Surface | While quarantined or killed |
|---|---|
Proxy (/v1/chat/completions, /v1/messages), agentfox.auto() model calls | Refused, rule agent.quarantined or agent.killed |
/v1/guard/tool_call, /v1/mcp/call, SDK guard_tool | Refused |
/v1/guard/input, /v1/guard/output, /v1/guard/memory_write | Not refused. They return their usual verdict (allow for ordinary content). |
| Approvals already pending for the agent | Still decidable. Approving one while the agent is quarantined succeeds, and code following the pattern above then runs the tool. |
Escalate to a kill, then bring the agent back when the cause is dealt with:
agentfox agents kill support-triage --reason "INC-212: confirmed, stop everything"
agentfox findings --severity critical
agentfox agents resume support-triage --reason "INC-212: grant narrowed to finance-approved accounts"
agentfox findings --severity criticalsupport-triage quarantined → killed INC-212: confirmed, stop everything
id severity type what
…hpct9jbp critical agent_stopped Agent 'support-triage' killed by cli
1 open finding(s).
…
support-triage killed → active INC-212: grant narrowed to finance-approved
accounts
No open findings at severity 'critical'.Stopping raises an agent_stopped finding (high for quarantine, critical for kill); resuming resolves it under the name of whoever resumed. agentfox agents list --stopped keeps listing an agent that has been resumed, with state active and the resume reason.
Over HTTP the same three actions are on the control plane:
curl -s -X POST http://localhost:8080/api/agents/payments-ops/quarantine \
-H "Authorization: Bearer $AGENTFOX_API_TOKEN" -H 'Content-Type: application/json' \
-d '{"reason":"INC-213: unexpected email.send volume"}'{"agent":"payments-ops","state":"quarantined","previous_state":"active","reason":"INC-213: unexpected email.send volume","actor":"marcus@example.com","changed_at":"2026-10-05T15:14:28.169251+00:00"}Quarantine needs the owner, admin, security or developer role; kill and resume need owner, admin or security. GET /api/agent-controls lists every agent not in its default state. The CLI acts on the database directly and records the actor as cli.
In the web app Agents → an agent → Kill switch
Incident runbook
Every command below was run against the example deployment above.
1. Stop the agent
bash agentfox agents quarantine payments-ops --reason "INC-213: unexpected email.send volume"Use
killinstead if you already know it is doing harm.2. Deny what it is waiting on
Pending approvals survive a quarantine. Deny the agent's pending approvals (
agentfox permit approvals list --agent payments-ops, thendeny ID), or over HTTP:bash API=http://localhost:8080 AUTH="Authorization: Bearer $AGENTFOX_API_TOKEN" AGENT_ID=$(curl -s "$API/api/agents/payments-ops" -H "$AUTH" | jq -r .id) for id in $(curl -s "$API/api/approvals?status=pending" -H "$AUTH" \ | jq -r --arg a "$AGENT_ID" '.approvals[] | select(.agent_id == $a) | .id'); do curl -s -X POST "$API/api/approvals/$id/deny" -H "$AUTH" \ -H 'Content-Type: application/json' -d '{"rationale":"INC-213: agent quarantined"}' echo doneOutput {"id":"apr_01m46a3vyhar5cmbqk","status":"denied","resolver":"marcus@example.com"} {"id":"apr_01m46a3vxyaj3yz8zz","status":"denied","resolver":"marcus@example.com"}3. See what it can reach
bash agentfox agents lineage support-triage agentfox permit list support-triageOutput support-triage — blast radius 4 support-triage --calls_tool--> billing.export (observed 8×) support-triage --uses_model--> gpt-4o-mini (observed 11×) … support-triage --connects_mcp--> tickets (observed 1×) id may call as long as …pjeckkc3 billing.export provenance up to user; a human approves …g3jqv96m crm.lookup provenance up to user …htby2gwa kb.search provenance up to retrieved …5pdrxe82 tickets.* provenance up to user 4 grant(s). Anything not listed is refused by default.4. See what it did
bash curl -s "$API/api/traces?agent=support-triage&since_days=1&limit=3" -H "$AUTH" agentfox findings --severity highOutput {"traces": [{"id": "trc_01m46a3dz3eaesavnp", "verdict": "block", "status": "blocked", "started_at": "2026-10-05T15:14:47.395890+00:00", …}, {"id": "trc_01m46a154ytg4v63tt", "verdict": "allow", "status": "ok", …}, …]}Open a trace with
GET /api/traces/{id}(see the gateway guide) or in the web app.5. Take away what it should not have
bash agentfox permit list support-triage --json agentfox permit revoke cap_01m469qwr2pjeckkc3 --yesOutput Revoke billing.export from support-triage cap_01m469qwr2pjeckkc3 argument limits — revoked billing.export from support-triage The grant is gone from the live set; the audit chain keeps what it was.The next
billing.exportcall is refused withcapability.denied. Re-grant narrower, for example with--limitor--max-taint.6. Resume, and check the record
bash agentfox agents resume support-triage --reason "INC-212: grant narrowed to finance-approved accounts" agentfox report verifyOutput support-triage killed → active INC-212: grant narrowed to finance-approved accounts chain: 52 entries, head seq 52, 0 checkpoints CHAIN INTACT — 52 entries verified (seq 1..52)The stop, the decisions on approvals and the resume are chain entries (
agent.quarantined,agent.killed,approval.denied,agent.resumed), readable withGET /api/audit/entries?action=agent.killedand included in evidence packages.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
The call escalated with "arguments ['…'] carry provenance above the capability's max_taint", but the grant has no --requires-approval. | An argument came from somewhere above the grant's max_taint (a document, a tool result). The reason names the argument and where it came from; pass a trusted value, or raise the grant's --max-taint. |
| A retry with the approval id files a new approval. | The reason ends with why the approval was not used: it was already used, is not yet approved, expired, or the arguments differ from the ones approved. Each approval lets one identical call through. |
A pending approval turned into expired. | Nobody decided within 30 minutes. Expiry denies. |
role 'developer' may not modify 'approvals' | Deciding needs owner, admin or security. Issue the token for such a user. |
report escalations says 0 conversations. | No turns were recorded. Send X-AgentFox-Session through the proxy, use agentfox.auto(), or post turns to /api/escalation/turns. |
A quarantined agent still gets allow. | You are calling /v1/guard/input, /output or /memory_write, which do not read the kill switch. |
Limits
- There is no CLI command to list or decide approvals; use the API or the web app.
- An approval does not authorise a retry, and it does not re-check the agent's state when it is decided.
- Approvals expire after 30 minutes; the window is not configurable per grant.
- Missed-escalation detection is after the fact and lexical; it finds conversations, it does not intervene in them.
- The kill switch is not read by
/v1/guard/input,/outputor/memory_write.
| You want to | Run |
|---|---|
| Require a person for a tool | agentfox permit grant support-triage billing.export --requires-approval |
| Declare when to hand off | agentfox declare escalation --agent support-triage --sla-minutes 30 |
| Find missed hand-offs | agentfox report escalations --hours 24 |
| Queue the missed ones | agentfox report escalations --apply |
| Stop an agent | agentfox agents quarantine support-triage --reason INC-212 |
| Stop it harder | agentfox agents kill support-triage --reason INC-212 |
| Bring it back | agentfox agents resume support-triage --reason INC-212 |