Approval Flow
When a policy rule has action: human_approval, the tool call enters an approval queue. The agent waits until a human (or a supervisor) resolves it, and the tool call then proceeds or is rejected.
How it works
Agent calls filesystem.write_file
│
├── Policy: human_approval
│
├── Check 1: Temporal grant active?
│ yes → bypass approval, proceed
│
├── Check 2: flux7-memory precedents? (read tools only,
│ enough human approvals of this tool + agent, no refusal)
│ yes → proceed, traced as supervisor:mem7
│
└── Submit to approval queue
│
├── MCP mode: routed per approval.channel (TTY prompt or queue);
│ with approval.wait_seconds, waits that long for a supervisor
├── HTTP mode: always the queue — blocks until resolved
│
└── Human/supervisor resolves
├── approved → tool call executed, result returned
├── denied → 403 returned
└── timeout → 408 returned (default 5 min)
Routing: approval.channel
In MCP mode, approval.channel decides where a human_approval request goes:
Version
Since v0.15.0. Older binaries silently ignore the key.
| Channel | Behavior |
|---|---|
queue |
Always enqueue. For daemons and supervisor setups, since a service has no terminal to prompt on. |
tty |
Require the interactive /dev/tty prompt. If no TTY is available, the call is denied (fail-closed), never silently queued. |
tty-fallback |
Try the TTY prompt, fall back to the queue. Default, which matches the historical behavior. |
Without an explicit channel, routing depends on how the process was launched: a daemon started from a terminal keeps a usable /dev/tty and will prompt in a window nobody watches. Set channel: queue for any unattended deployment (systemd, container, supervisor loop).
The HTTP proxy path (POST /tool/{name}) always uses the queue regardless of this setting.
Waiting for an automatic decision: approval.wait_seconds
In MCP mode a human_approval call is non-blocking: the agent gets the approval id at once and retries the same call once it is approved. When a supervisor such as sup7 decides in about half a second, that retry is friction, and an LLM that rewrites its arguments on retry opens a new approval.
With a wait, the call holds for up to wait_seconds. Decided in time, it runs (or is refused) in the same request, and the approval is used up, so a retry of the same call does not run it again. Not decided in time (a human, a slow supervisor), the agent gets the approval id as before. Keep it short: a stdio session is blocked while it waits, and a human never answers within it. Pair it with a supervisor that polls faster than the wait (sup7 poll.interval: 500ms).
Measured in production: a write sent to approval, approved by a sup7 rule in 447 ms, ran in the same request, without a retry.
Changing approval settings at runtime
GET /approvals/settings and PUT /approvals/settings (control plane) read and change approval.timeout_seconds, approval.wait_seconds, supervisor.auto_approve, supervisor.min_approvals and supervisor.auto_approve_writes without a restart.
curl -s -X PUT http://localhost:9090/approvals/settings -H "Content-Type: application/json" \
-d '{"timeout_seconds":300,"wait_seconds":3,"auto_approve":true,"min_approvals":3,"auto_approve_writes":false,"by":"alice"}'
A change is:
- validated: timeout 30 to 3600 s, wait 0 to 10 s, human approvals needed 1 to 100;
- written back to the config file line by line, through the YAML tree: comments, key order and file mode are kept, and the previous version is saved as
<config>.bak-<timestamp>(never overwriting an earlier backup); - read back to check the file says what was asked (otherwise the file is restored);
- applied at once to every session;
- traced as
mesh.approval_settings_edit, with the values before and after.
flux7-console edits them from the Approvals page.
Resolving approvals
In Claude Code (MCP mode): approve, then retry
When no terminal prompt is available (Claude Code runs mesh7 without one), the call returns at once:
Approval required (id: d9a80d29) for filesystem.write_file, valid 299s.
Ask the user to approve it, with one of:
mesh approve d9a80d29 (terminal)
the Approvals page of flux7-console
POST /approvals/d9a80d29/approve on the mesh HTTP port
Once approved, call filesystem.write_file again with the same arguments: the approved call runs once.
The agent relays this to its user. Once a human approves, the agent's retry of the same tool with the same arguments, by the same agent, within the approval's validity, runs, and uses up the approval: a second retry asks again, and a call with other arguments gets its own approval. The agent itself cannot approve (see below). Since v0.17.0; earlier versions pointed the agent at approval.resolve, which a regular agent cannot call.
An agent can also wait for the decision by retrying the same call on a timer:
- While the approval is pending, each retry meets the approval already open: same id, no new request in the queue, no new trace line.
- Once a human denies it, the next retry learns the refusal, once, as an error:
Approval denied by <who> for <tool> (id: …): do not retry this call.The agent can stop waiting; a later call is a new request for a human.
Both since the release after v0.17.2 (on main). Before, every retry while pending opened another approval (five retries, six requests), and after a denial the agent could only keep asking until its patience ran out.
Via MCP virtual tools
The approval.* virtual tools are operator-only: only a declared supervisor
agent (supervisor.supervisor_agents glob) may list or resolve approvals over
MCP. A regular agent cannot, otherwise it could approve its own pending
human_approval request and defeat the gate. With no supervisor configured,
resolve approvals via the CLI or HTTP API below.
approval.pending → list pending approvals (supervisor agents only)
approval.resolve {id, decision} → approve or deny (supervisor agents only)
Via CLI
# List pending
mesh pending
# Approve (prefix match)
mesh approve a1b2c3d4
# Approve, and stop being asked about this exact tool for an hour.
# The grant records this approval as its origin (see "Chain of authority").
mesh approve a1b2c3d4 --grant 1h
# Same, widening the grant beyond the single tool (deliberate)
mesh approve a1b2c3d4 --grant 1h --tools "filesystem.write_*"
# Deny
mesh deny a1b2c3d4
# Watch (live updates): [a]pprove, [g]rant, [d]eny, [s]kip
mesh watch
In watch, [g] approves and opens a grant in one keystroke. Its duration comes
from MESH_GRANT_DURATION (default 1h), and its pattern is the exact tool
approved. A failing grant never fails the approval: the call was already let
through, and losing the shortcut is the lesser harm.
Via HTTP API
# List all approvals
curl http://localhost:9090/approvals
# Get details (includes recent traces and active grants)
curl http://localhost:9090/approvals/a1b2c3d4e5f6g7h8
# Approve
curl -X POST http://localhost:9090/approvals/a1b2c3d4/approve \
-H "Content-Type: application/json" \
-d '{"resolved_by":"user:marc","reasoning":"routine operation","confidence":0.95}'
# Deny
curl -X POST http://localhost:9090/approvals/a1b2c3d4/deny \
-d '{"resolved_by":"user:marc","reasoning":"unexpected target"}'
Prefix matching: a1b2c3d4 matches the full ID if the prefix is unique.
Temporal grants
Repeated approvals for the same tool pattern get tedious. Grants work like sudo, as a temporary bypass:
For the next 30 minutes, all filesystem.write_* calls bypass the approval queue. Traced as grant:<id>.
Chain of authority
A grant issued out of nowhere is an orphan: it authorizes calls without saying why it exists, and "why was this allowed?" stops at "because a grant covered it".
So a grant can record its origin, the approval and the call it answers:
curl -X POST http://localhost:9090/grants -d '{
"agent": "claude", "tools": "filesystem.write_*", "duration": "1h",
"approval_id": "<the approval>", "trace_id": "<the call being approved>"
}'
mesh approve <id> --grant <duration> and [g] in watch fill both fields on
their own; nobody copies an ID by hand. Every call the grant later waves through
then carries grant_id and parent_trace_id, and the chain is walkable:
The response is JSON (trace_id, chain_length, chain); the chain array
holds the full trace entries, oldest first. Reduced to the fields that matter:
0. fa12168e echo7.run human_approval rule=demo
1. 384b0ab7 echo7.run allow rule=grant:a9b240ef grant=a9b240ef
depth bounds the walk (default 10, capped at 50). The same edge appears as
parentSpanId in the OTLP export, so Jaeger or Tempo renders the tree directly
(see OpenTelemetry). The flux7-console trace detail reads the same
endpoint and shows the chain (approval, grant, call) next to the trace.
Origin is always optional. A grant issued without one still works and yields a chain of one, which is an honest answer rather than a gap.
MCP tools
grant.create {tools: "filesystem.*", duration: "1h"} (supervisor agents only)
grant.list (everyone, read-only)
grant.revoke {id: "abc123"} (supervisor agents only)
grant.create and grant.revoke are operator-only, like approval.*: only an
agent matching supervisor.supervisor_agents sees or calls them. Otherwise an
agent could grant itself a bypass of its own human_approval gate. Everyone
else creates grants with the CLI (mesh approve --grant) or the HTTP API.
HTTP API
# Create
curl -X POST http://localhost:9090/grants \
-d '{"agent":"claude","tools":"filesystem.*","duration":"30m"}'
# List
curl http://localhost:9090/grants
# Revoke
curl -X DELETE http://localhost:9090/grants/abc123
Grants only bypass human_approval
Tools marked deny remain blocked. A grant cannot override a deny rule; overriding one requires a policy edit.
Timeouts
Unanswered approvals time out after 5 minutes (configurable):
Timed-out approvals are recorded in traces and written to flux7-memory (if configured) with status timeout.
Webhooks
Get notified when a new approval is pending:
flux7-mesh POSTs to this URL with the pending approval details. Useful for Slack/Teams alerts.
Callback URL
HTTP agents can provide a callback URL to receive the resolution:
curl -X POST http://localhost:9090/tool/gmail.send_email \
-H "Authorization: Bearer agent:my-bot" \
-H "X-Callback-URL: http://my-bot:8080/approval-callback" \
-d '{"params":{"to":"user@example.com","subject":"Hello"}}'
When the approval resolves, flux7-mesh POSTs the result to X-Callback-URL.
Supervisor mode
The approval and grant-mutating tools are hidden from every agent that does not match supervisor_agents, whether or not supervisor mode is on. With supervisor.enabled: true, an external supervisor resolves approvals:
supervisor:
enabled: true
expose_content: false # redact params for the supervisor
supervisor_agents: # whitelist (glob) for cloud supervisors
- "supervisor-*"
In this mode, MCP tool calls block until the supervisor resolves. The agent waits transparently.
See Supervisor Protocol for the external resolver API.
Traces
Every approval is traced:
{
"tool": "filesystem.write_file",
"policy": "human_approval",
"approval_id": "a1b2c3d4e5f6g7h8",
"approval_status": "approved",
"approved_by": "user:marc",
"supervisor_reasoning": "routine write operation",
"supervisor_confidence": 0.95,
"latency_ms": 4200
}
Query approval history:
Decision persistence
When flux7-memory is configured, every approval resolution (approve, deny, timeout) is stored as a queryable fact. This enables:
- Auto-approve: reads a human approved before resolve without asking again (writes always ask)
- Audit trail: "who approved what, when, why" is queryable
- Cross-session memory: decisions survive process restarts
Next steps
- Memory Integration: auto-approve from past decisions
- CLI Tools: governing git, docker, terraform
- Deployment Modes: solo, team, cloud