Tool Approval API
Tool approval policies define gates that intercept tool calls before execution. Three strategy types are supported: required (always blocks for a human decision), policy (evaluates a Rego policy against the call context), and llm (delegates to an LLM judge). When a gate trips, the session parks on _approval and a decision must be submitted via the respond endpoint before execution continues.
The approval gate lifecycle, park/resume model, and console walkthrough.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/tool_approval_policies |
List policies |
| POST | /v1/tool_approval_policies |
Create a policy |
| POST | /v1/tool_approval_policies/find |
Find policies by predicate |
| POST | /v1/tool_approval_policies/invalidate |
Flush the in-process policy cache |
| GET | /v1/tool_approval_policies/{entity_id} |
Get a policy |
| PUT | /v1/tool_approval_policies/{entity_id} |
Replace a policy |
| DELETE | /v1/tool_approval_policies/{entity_id} |
Delete a policy |
| GET | /v1/sessions/{session_id}/tool_approval/pending |
Get pending approval for a session |
| POST | /v1/sessions/{session_id}/tool_approval/respond |
Submit an approval decision for a session |
Chats expose a read-only GET /v1/chats/{chat_id}/tool_approval/pending but do not expose tool_approval/respond. On a chat, approval is conversational: when a policy gates a tool call the agent ends its turn with a normal assistant message asking you to approve, and you reply with a normal chat message (for example "yes" or "no").
ToolApprovalPolicy object
{
"id": "gate-delete-files",
"toolset_id": "workspaces",
"tool_name": "fs_delete",
"approval": {"type": "required"},
"enabled": true,
"timeout_seconds": 300
}
| Field | Required | Type | Description |
|---|---|---|---|
id |
no | string | Identifier. If omitted, the server assigns a type-prefixed id (e.g. tool-approval-policy-3f9a1c8d). Immutable after creation |
toolset_id |
yes | string | Toolset the policy applies to; may be a reserved built-in toolset (system, workspaces, search, misc, web, harness, trigger, workspace_ext) or a user-created Toolset id |
tool_name |
yes | string | Bare tool name as registered in the provider catalogue (min length 1) |
approval |
yes | ApprovalConfig | Discriminated approval strategy (see below) |
enabled |
no | boolean | Default true; disabled policies are stored but skipped at evaluation time |
timeout_seconds |
no | number or null | Per-policy timeout in seconds (must be > 0). null falls back to the global yield cap |
The pair (toolset_id, tool_name) must be unique. A duplicate returns 409 /errors/conflict naming both fields. This constraint also applies on PUT: the check skips the row being updated so a policy can be replaced in place without a false duplicate.
Approval strategies:
required: gate trips unconditionally; every call waits for an operator decision:
{"type": "required"}
policy: evaluate a Rego policy source. The policy must declare package primer.tool_approval and expose a boolean required key (and an optional reason string). The source is compile-tested server-side at create/update time; malformed Rego returns 422 and never persists:
{
"type": "policy",
"policy": "package primer.tool_approval\n\ndefault required := false\n\nrequired {\n input.arguments.amount > 10000\n}\n"
}
llm: delegate to an LLM judge. The provider_id must reference an existing LLMProvider row and model must be in that provider's models list; both are validated server-side at create/update time:
{
"type": "llm",
"provider_id": "anthropic-prod",
"model": "claude-sonnet-4-6",
"prompt": "Decide whether this tool call should be approved. Approve only if the operation is safe and expected."
}
| Field | Required | Description |
|---|---|---|
provider_id |
yes | Id of an existing LLMProvider |
model |
yes | Model name from that provider's models list |
prompt |
yes | System prompt sent to the judge (1-16000 chars); call context is appended as the user message |
Create a policy
POST /v1/tool_approval_policies - returns 201 Created.
curl -X POST https://your-host/v1/tool_approval_policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "gate-delete-files",
"toolset_id": "workspaces",
"tool_name": "fs_delete",
"approval": {"type": "required"},
"enabled": true,
"timeout_seconds": 300
}'import httpx
r = httpx.post(
"https://your-host/v1/tool_approval_policies",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "gate-delete-files",
"toolset_id": "workspaces",
"tool_name": "fs_delete",
"approval": {"type": "required"},
"enabled": True,
"timeout_seconds": 300,
},
)
assert r.status_code == 201const r = await fetch("/v1/tool_approval_policies", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "gate-delete-files",
toolset_id: "workspaces",
tool_name: "fs_delete",
approval: {type: "required"},
enabled: true,
timeout_seconds: 300
})
})Errors:
409-(toolset_id, tool_name)pair already has a policy422- required field missing, Rego compile failed, or LLM provider/model not found
Get a policy
GET /v1/tool_approval_policies/{entity_id} - returns 200 OK.
curl https://your-host/v1/tool_approval_policies/gate-delete-files \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/tool_approval_policies/gate-delete-files",
headers={"Authorization": f"Bearer {token}"})
pol = r.json()const r = await fetch("/v1/tool_approval_policies/gate-delete-files", {
headers: {"Authorization": `Bearer ${token}`}
})Errors: 404 if the id does not exist.
List policies
GET /v1/tool_approval_policies - returns an offset or cursor page of policy objects.
Query parameters: limit (1-200, default 20), offset (default 0), cursor, order_by.
curl "https://your-host/v1/tool_approval_policies?limit=20" \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/tool_approval_policies",
headers={"Authorization": f"Bearer {token}"})
policies = r.json()["items"]const r = await fetch("/v1/tool_approval_policies?limit=20", {
headers: {"Authorization": `Bearer ${token}`}
})
const {items} = await r.json()Replace a policy
PUT /v1/tool_approval_policies/{entity_id} - full replacement; returns 200 OK. The same validation rules apply as on POST (Rego compile-test, LLM provider lookup, uniqueness skips own id).
curl -X PUT https://your-host/v1/tool_approval_policies/gate-delete-files \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "gate-delete-files",
"toolset_id": "workspaces",
"tool_name": "fs_delete",
"approval": {"type": "required"},
"enabled": false,
"timeout_seconds": 120
}'import httpx
r = httpx.put(
"https://your-host/v1/tool_approval_policies/gate-delete-files",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "gate-delete-files",
"toolset_id": "workspaces",
"tool_name": "fs_delete",
"approval": {"type": "required"},
"enabled": False,
"timeout_seconds": 120,
},
)await fetch("/v1/tool_approval_policies/gate-delete-files", {
method: "PUT",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "gate-delete-files",
toolset_id: "workspaces",
tool_name: "fs_delete",
approval: {type: "required"},
enabled: false,
timeout_seconds: 120
})
})Delete a policy
DELETE /v1/tool_approval_policies/{entity_id} - returns 204 No Content.
curl -X DELETE https://your-host/v1/tool_approval_policies/gate-delete-files \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.delete(
"https://your-host/v1/tool_approval_policies/gate-delete-files",
headers={"Authorization": f"Bearer {token}"},
)await fetch("/v1/tool_approval_policies/gate-delete-files", {
method: "DELETE",
headers: {"Authorization": `Bearer ${token}`}
})Invalidate the policy cache
POST /v1/tool_approval_policies/invalidate - flushes the in-process approval policy cache so the running engine picks up newly created or deleted policies without waiting for the next cache refresh. Returns 202 Accepted.
Call this after creating or deleting a policy if the change needs to take effect immediately for in-flight sessions.
curl -X POST https://your-host/v1/tool_approval_policies/invalidate \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.post(
"https://your-host/v1/tool_approval_policies/invalidate",
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 202await fetch("/v1/tool_approval_policies/invalidate", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`}
})Get pending approval
When an enabled policy gates a tool call, the session parks on _approval. The pending endpoint returns the queued call details.
GET /v1/sessions/{session_id}/tool_approval/pending - returns 200 OK with the pending call, or 404 Not Found if there is no pending approval. A read-only chat variant exists at GET /v1/chats/{chat_id}/tool_approval/pending; chats have no respond endpoint, so on a chat the agent asks for approval as a normal assistant message instead.
curl https://your-host/v1/sessions/sess-abc123/tool_approval/pending \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get(
"https://your-host/v1/sessions/sess-abc123/tool_approval/pending",
headers={"Authorization": f"Bearer {token}"},
)
pending = r.json()
tool_call_id = pending["tool_call_id"]const r = await fetch("/v1/sessions/sess-abc123/tool_approval/pending", {
headers: {"Authorization": `Bearer ${token}`}
})
const pending = await r.json()Submit an approval decision
POST /v1/sessions/{session_id}/tool_approval/respond - submit an approved or rejected decision. Returns 202 Accepted; the session then resumes with the result. Sessions only; on a chat you approve or reject by sending a normal chat message in reply to the agent's request.
Request body:
| Field | Required | Type | Description |
|---|---|---|---|
tool_call_id |
yes | string | The tool call id from the pending response |
decision |
yes | string | "approved" or "rejected" |
reason |
no | string or null | Optional human-readable reason (max 1024 chars); surfaced to the agent in the tool result on rejection |
curl -X POST https://your-host/v1/sessions/sess-abc123/tool_approval/respond \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tool_call_id": "tc-xyz789",
"decision": "approved"
}'import httpx
r = httpx.post(
"https://your-host/v1/sessions/sess-abc123/tool_approval/respond",
headers={"Authorization": f"Bearer {token}"},
json={
"tool_call_id": "tc-xyz789",
"decision": "approved",
},
)await fetch("/v1/sessions/sess-abc123/tool_approval/respond", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
tool_call_id: "tc-xyz789",
decision: "approved"
})
})To reject with a reason:
curl -X POST https://your-host/v1/sessions/sess-abc123/tool_approval/respond \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tool_call_id": "tc-xyz789",
"decision": "rejected",
"reason": "File deletion not permitted during business hours."
}'import httpx
r = httpx.post(
"https://your-host/v1/sessions/sess-abc123/tool_approval/respond",
headers={"Authorization": f"Bearer {token}"},
json={
"tool_call_id": "tc-xyz789",
"decision": "rejected",
"reason": "File deletion not permitted during business hours.",
},
)await fetch("/v1/sessions/sess-abc123/tool_approval/respond", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
tool_call_id: "tc-xyz789",
decision: "rejected",
reason: "File deletion not permitted during business hours."
})
})Errors: 404 if the session does not exist; 422 if tool_call_id is missing or decision is not a valid value.
Timeout behavior: if no decision arrives before timeout_seconds elapses, the TimeoutSweeper synthesises a rejected result (reason "timed-out") and the session resumes with that rejection. The session does not terminate; the agent receives the rejection and continues.
Errors note
All error responses use the RFC 7807 ProblemDetails envelope with type, title, status, detail, instance, and extensions (which includes request_id and, for 422 errors, an errors array with field paths). See the REST API overview for details.