Approvals
A queue of human decisions. When an agent proposes a risky action, the platform files an approval item with the frozen proposed action, supporting evidence and a hard expiry; a human approves, edits-then-approves, or rejects it.
Overview
Approvals are producer-agnostic: one item model, one expiry path, one decision output shape. Items are created by the platform only; there is no public create endpoint. Producers:
- the
approvalorchestration node (origin: node); - tool-call interception: a guardrail on a project,
agent, or tool gates tool calls in chat sessions, direct generations and MCP
(
origin: tool_call); - approval-gated task transitions: a workflow transition declaring
requires_approval(origin: task_transition). The item carries noproposed_action; it gates the transition named bytask_transitionontask_id.
origin is for filtering only; the lifecycle never branches on it.
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Approval Gates - Step 7 (Approve it — the run resumes)
- Gate a Dangerous Tool with Guardrails - Step 9 (A class-C call parks for sign-off)
- Close the Monthly Books - Step 11 (Sign off: the human decides, the guard has the last word) — a
requires_approvaltransition item.
Data Model
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (apr_…) |
project_id | string | ID of the owning project |
origin | string | node | tool_call | task_transition — producer origin (analytics/filtering only) |
status | string | pending | approved | rejected | expired |
proposed_action | object | null | Frozen { tool_id, action?, arguments } the decision governs; null for task_transition items. action is present for tool_call-origin items (always, even for single-action tools) and omitted for node-origin items, whose downstream execution is wired by a separate tool node in the graph |
reasoning | string | null | The proposing agent's rationale |
evidence | object | null | Structured supporting data |
predicted_impact | string | null | Expected execution effect |
expires_at | string | Server-enforced hard gate; the item can never execute after this |
dedup_key | string | null | Set on tool-call items to suppress duplicate proposals |
orchestration_run_id | string | null | Originating orchestration run (node producer) |
node_id | string | null | Originating node id within the run's graph |
generation_id | string | null | Originating generation (tool-call producer) |
session_id | string | null | Session the originating generation ran in (tool-call producer) |
agent_id | string | null | Proposing agent |
task_id | string | null | Gated task (task_transition producer) |
task_transition | string | null | Transition fired on approval (task_transition producer) |
policy_version | string | null | Guardrail policy version that routed here |
previous_item_id | string | null | Prior item's ID when this proposal was re-filed after an earlier matching item (same dedup_key) was rejected |
resolved_by | string | null | Resolving user's public ID; null on expiry |
resolution_reason | string | null | Required on rejection |
edited_arguments | object | null | Set on edit-then-approve; the original stays in proposed_action |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Key Concepts
Snapshot at emit time
proposed_action, reasoning, evidence and predicted_impact are resolved
at emit time and frozen onto the item; later state changes never alter what
the approver sees.
How producers suspend and resume
approvalnode: the run parks. The node emits the item and parks the run asawaiting_input. Resolution re-enqueues the run with the decision output as the node result, routingapproved/rejected/on_expirededges.- Task transition: the gate parks. The transition files the item and sets
pending_transitionon the task; no other transition may fire until it resolves. Approval fires the transition as theapprovalprincipal (guard re-evaluated then); rejection or expiry clears the gate and appends a note to the task's history. See Workflows. - Tool-call interception: return-pending. The intercepted call files the
item and returns
{ "status": "pending_approval", "approval_id": "apr_…", "expires_at": "…" }as the tool result; the generation completes its turn. On resolution the platform starts a continuation generation (linked viainitiator_generation_id) feeding the decision output back to the agent. On approval the frozen (or edited) arguments execute first and the tool's output becomes the decision'sresult; on rejection nothing executes. Expiry ends the chain unless the agent setson_approval_expiry: "react", in which case the continuation carries{ "decision": "expired" }(see Agents → Approval Expiry); theexpiredrow, theapprovals.expiredevent and the auto-filed exception are the record. When the original generation ran in a session or conversation, the continuation's messages append there. - The continuation runs the agent's own config, including
tool_choice: a forcing agent reports the decision by reaching itshas_tool_callstop condition, which is why that condition is mandatory for it.
Continuation identity
A continuation runs as the principal that started the chain, never as the
approver. The platform reads the principal persisted on the proposing
generation
(started_by_principal_type / started_by_principal_id)
and re-mints a short-lived run-as token; the continuation's
builtin tools and the approved action execute with it.
The token asserts identity only; authorization is evaluated per request, so a
chain started by a scoped API key never exceeds that key's policies, and
revoking the key stops the chain mid-flight.
The continuation records the same principal on its own generation, so later approvals in the chain re-mint from there. A chain with no recorded principal (started by a trigger or an OAuth token) gets no credential; its self-calls stay unauthenticated.
The proposing turn's tool_context is not stored
on the item. A tool that authorizes through a context header gets its value
from the approver: tool_context on the approve call is forwarded on the
approved action and on every tool call of the continuation turn, exactly as on
a generation. Use it when the credential is minted at decision time, since a
per-turn token may have expired while the item waited.
Duplicate proposals (dedup)
Tool-call items carry a dedup_key derived from agent, tool, action and
resolved arguments. While a matching item is pending, a duplicate emit
returns the existing item (its approval_id in the tool result). Once it
resolves, the same proposal files a fresh item; after a rejected one, the
fresh item's previous_item_id links back to it. Node-produced items are not
deduplicated; each run pauses once per approval node.
Recurrence view
GET /api/v1/approvals/recurrences
is a read-only rollup grouping items by dedup_key, most-recurrent first.
Each group carries agent_id, tool_id, count, the ordered item chain
(the previous_item_id thread, oldest → newest), and reasons in order.
status(defaultrejected) selects the lifecycle state grouped.min_count(default2) is the floor for a group to be returned.- Grouping is exact-key only; no semantic clustering.
A recurring correction belongs in a guardrail deny or the
agent's instructions
(agent versions archive every
write), not in memories.
Expiry is a hard gate
Expiry is enforced server-side in both directions:
- A background sweeper flips overdue
pendingitems toexpiredand emitsapprovals.expired. - The resolution path re-checks
expires_atat decision time; an expired item returns409 APPROVAL_EXPIREDand never executes.
Approve, reject, edit-then-approve
- Approve resolves the item and resumes its producer: an
approvalnode routes down itsapprovededge (a downstreamtoolnode acts on the arguments); a tool-call item has its arguments executed by the platform, the result flowing into the continuation generation. - Edit-then-approve replaces the arguments via
argumentson the approve call. They must be a JSON object satisfying the tool'sparametersschema (400 APPROVAL_INVALID_EDITotherwise); the original stays inproposed_action, the edit inedited_arguments. Editing needs more authority; see Who may resolve. tool_contexton the approve call is forwarded to the approved action and the continuation turn, never stored (see Continuation identity). The server-pinned identity keys (session_id,actor_id,actor_external_id) are dropped from it: the approver decides whether the action runs, not who it runs for. A key that is not a valid header name answers400 INVALID_TOOL_CONTEXT_KEYand nothing is resolved.- Reject requires a
reason, preserved on the item.
Decision output
The approval node consumes the decision as its node result; a tool-call
continuation as the tool result. Same shape:
{
"decision": "approved",
"approval_id": "apr_x1y2z3a4b5c6d7e8",
"resolved_by": "user_a1b2c3d4e5f6g7h8",
"edited_args": { "amount": 450 },
"reason": null,
"result": null
}
decision—approved|rejected|expiredresolved_by— resolving user's public ID;nullon expiryedited_args—nullunless edit-then-approvereason— required (non-null) on rejectionresult— the executed tool output on approval fortool_callitems;nullfornodeitems (execution belongs to the downstreamtoolnode)
Who may resolve
Any principal with approvals:ResolveApproval in the project may resolve any
of its items; there is no per-item assignment.
Editing the arguments takes more than resolving, since the approved action executes under the proposing generation's principal. An edit additionally requires what making the call would require:
| Proposal | Also required to edit |
|---|---|
| any tool | tools:CallTool on that tool |
a builtin tool | the proposed action's own IAM action, anywhere in the project |
A builtin action is dispatched in-process against the proposer's credential, so nothing else checks the approver. An edit failing either check answers 403 FORBIDDEN; approving as proposed is unaffected.
Examples
List pending approvals
- CLI
- SDK
- curl
soat list-approvals --project-id proj_ABC --status pending
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'https://api.example.com', token: 'sk_...' });
const { data, error } = await soat.approvals.listApprovals({
query: { project_id: 'proj_ABC', status: 'pending' },
});
if (error) throw new Error(JSON.stringify(error));
curl -X GET "https://api.example.com/api/v1/approvals?project_id=proj_ABC&status=pending" \
-H "Authorization: Bearer <token>"
Approve (optionally with edited arguments)
- CLI
- SDK
- curl
soat approve-approval --approval-id apr_01 --arguments '{"amount": 450}'
const { data, error } = await soat.approvals.approveApproval({
path: { approval_id: 'apr_01' },
body: { arguments: { amount: 450 } },
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/approvals/apr_01/approve \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"arguments": {"amount": 450}}'
Reject with a reason
- CLI
- SDK
- curl
soat reject-approval --approval-id apr_01 --reason "Exceeds monthly budget"
const { data, error } = await soat.approvals.rejectApproval({
path: { approval_id: 'apr_01' },
body: { reason: 'Exceeds monthly budget' },
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/approvals/apr_01/reject \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"reason": "Exceeds monthly budget"}'