Skip to content

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.

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’s mcp-global-governance, or require approval per capability on the (group, server) binding. approval_timeout_seconds sets how long a pending request stays open.
  • LLM tool use: tool_use.access: "approval_required" in llm-global-governance.
  • Skills / A2A / agent grants: agent authorization grants can carry an approval_required decision, producing the same flow.
Terminal window
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
}'
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 consumed

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 Accepted with 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:

Tool call confirmation in the User Portal

If nobody decides before expires_at, the request transitions to expired and the caller must start over.

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:

A held call waiting for human approval

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
Terminal window
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"
}

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:

Terminal window
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:

Terminal window
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}}}'

Every decision is durable evidence:

  • The approve/reject action writes a proxy-log row with proxy_subtype = approval_approved or approval_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).