Tool Approvals
Some tool calls shouldn’t run just because a model asked for them. Brutor’s approval flow inserts a human between the request and the execution: the call is held, a group member approves or rejects it, and the caller retries with a one-time token.
Approvals work on three surfaces: MCP tools, A2A agent calls, and agent skills.
Turning approvals on
Section titled “Turning approvals on”Approval requirements are part of governance configuration on the resource group:
- MCP: set
default_tool_access: "approval_required"(and/or the resource/prompt equivalents) in the group’smcp-global-governance, or require approval per capability on the (group, server) binding.approval_timeout_secondssets how long a pending request stays open. - LLM tool use:
tool_use.access: "approval_required"inllm-global-governance. - Skills / A2A / agent grants: agent authorization grants can carry an
approval_requireddecision, producing the same flow.
curl -X PATCH http://localhost:5050/v1/admin/resource-groups/{group_id}/mcp-global-governance \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "default_tool_access": "approval_required", "approval_timeout_seconds": 3600 }'The flow
Section titled “The flow”Agent calls tool ──▶ Gateway holds the call, creates approval request (expires in timeout window) │ ▼ Approver (member of the requesting group) sees it in the portal inbox approves ─┴─ rejects │ └─▶ request marked rejected; caller's poll returns "rejected" ▼ One-time approval_token issued │ ▼Agent retries the SAME call with X-Approval-Token: <token> ──▶ executes; token consumed1. The call is held
Section titled “1. The call is held”When a governed call requires approval, the caller doesn’t get the tool result — it gets an approval request:
- Programmatic callers (API key / agent):
202 Acceptedwith the request details:
{ "approval_required": true, "approval_request_id": "apr-...", "poll_url": "/v1/portal/approvals/apr-.../poll", "timeout_seconds": 3600, "expires_at": "2026-07-02T15:04:05Z"}- Interactive portal users get
428 Precondition Required, which the User Portal turns into an inline confirmation card:

If nobody decides before expires_at, the request transitions to expired and the caller must start over.
2. A group member decides
Section titled “2. A group member decides”Approval requests are visible to members of the resource group the request was made in — both direct members and users who belong via an end-user group. They surface in the User Portal’s approval inbox, or via the Portal API:

| Endpoint | Purpose |
|---|---|
GET /v1/portal/approvals?status=pending |
List requests (filter: pending, approved, rejected, expired, all) |
GET /v1/portal/approvals/pending-count |
Badge count for inbox UIs |
GET /v1/portal/approvals/{id} |
Full request detail (tool, arguments, requester) |
POST /v1/portal/approvals/{id}/approve |
Approve — body {"note": "..."} optional |
POST /v1/portal/approvals/{id}/reject |
Reject — body {"note": "..."} optional |
curl -X POST http://localhost:8100/v1/portal/approvals/apr-.../approve \ -H "Authorization: Bearer $PORTAL_JWT" \ -H "Content-Type: application/json" \ -d '{"note": "OK for this quarter'\''s export"}'{ "id": "apr-...", "status": "approved", "resolved_by": "42", "resolved_at": "2026-07-02T14:31:09Z", "approval_token": "Zk3v...128-char-base64...", "message": "Approval granted"}3. The agent polls and retries
Section titled “3. The agent polls and retries”Agents poll the lightweight status endpoint — it accepts API-key auth as well as portal JWTs, so the same credential that made the tool call can poll:
curl http://localhost:8100/v1/portal/approvals/apr-.../poll \ -H "X-API-Key: sk_brutor_api_..."{ "id": "apr-...", "status": "approved", "approval_token": "Zk3v...", "expires_at": "2026-07-02T15:04:05Z" }status is one of pending, approved, rejected, expired, consumed. On approved, retry the same call with the token:
curl -X POST http://localhost:8100/v1/proxy/mcp/hubspot-server \ -H "X-API-Key: sk_brutor_api_..." \ -H "X-Approval-Token: Zk3v..." \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"export_contacts","arguments":{"limit":5000}}}'import time, requests
BASE = "http://localhost:8100"HDRS = {"X-API-Key": "sk_brutor_api_..."}call = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "export_contacts", "arguments": {"limit": 5000}}}
r = requests.post(f"{BASE}/v1/proxy/mcp/hubspot-server", json=call, headers=HDRS)if r.status_code == 202 and r.json().get("approval_required"): poll_url = BASE + r.json()["poll_url"] while True: s = requests.get(poll_url, headers=HDRS).json() if s["status"] != "pending": break time.sleep(5) if s["status"] == "approved": call["id"] = 2 r = requests.post(f"{BASE}/v1/proxy/mcp/hubspot-server", json=call, headers={**HDRS, "X-Approval-Token": s["approval_token"]})const BASE = "http://localhost:8100";const HDRS = { "X-API-Key": "sk_brutor_api_...", "Content-Type": "application/json" };const call = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "export_contacts", arguments: { limit: 5000 } } };
let res = await fetch(`${BASE}/v1/proxy/mcp/hubspot-server`, { method: "POST", headers: HDRS, body: JSON.stringify(call) });let body = await res.json();
if (res.status === 202 && body.approval_required) { let status; do { await new Promise(r => setTimeout(r, 5000)); status = await (await fetch(BASE + body.poll_url, { headers: HDRS })).json(); } while (status.status === "pending");
if (status.status === "approved") { res = await fetch(`${BASE}/v1/proxy/mcp/hubspot-server`, { method: "POST", headers: { ...HDRS, "X-Approval-Token": status.approval_token }, body: JSON.stringify({ ...call, id: 2 }), }); }}Audit trail
Section titled “Audit trail”Every decision is durable evidence:
- The approve/reject action writes a proxy-log row with
proxy_subtype=approval_approvedorapproval_rejected, spliced into the originating request’s trace tree — so in the logs you see the blocked call, the human decision (who, when, note), and the retried execution as one causal chain. - The blocked call itself is recorded with
proxy_subtype = approval_required. - Request records carry the requester type (
portal_user,api_key,agent) and the surface (mcp,a2a,skill).
Related pages
Section titled “Related pages”- Users, groups & API keys — who counts as an eligible approver
- Budgets, quotas & rate limits — the
tool_usegovernance document that can trigger approvals - Build an agent — where the polling pattern fits in an agent loop
