Skip to main content

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 approval orchestration 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 no proposed_action; it gates the transition named by task_transition on task_id.

origin is for filtering only; the lifecycle never branches on it.

See the Permissions Reference for the IAM action strings for this module.

Data Model​

FieldTypeDescription
idstringPublic identifier (apr_…)
project_idstringID of the owning project
originstringnode | tool_call | task_transition — producer origin (analytics/filtering only)
statusstringpending | approved | rejected | expired
proposed_actionobject | nullFrozen { 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
reasoningstring | nullThe proposing agent's rationale
evidenceobject | nullStructured supporting data
predicted_impactstring | nullExpected execution effect
expires_atstringServer-enforced hard gate; the item can never execute after this
dedup_keystring | nullSet on tool-call items to suppress duplicate proposals
orchestration_run_idstring | nullOriginating orchestration run (node producer)
node_idstring | nullOriginating node id within the run's graph
generation_idstring | nullOriginating generation (tool-call producer)
session_idstring | nullSession the originating generation ran in (tool-call producer)
agent_idstring | nullProposing agent
task_idstring | nullGated task (task_transition producer)
task_transitionstring | nullTransition fired on approval (task_transition producer)
policy_versionstring | nullGuardrail policy version that routed here
previous_item_idstring | nullPrior item's ID when this proposal was re-filed after an earlier matching item (same dedup_key) was rejected
resolved_bystring | nullResolving user's public ID; null on expiry
resolution_reasonstring | nullRequired on rejection
edited_argumentsobject | nullSet on edit-then-approve; the original stays in proposed_action
created_atstringISO 8601 creation timestamp
updated_atstringISO 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​

  • approval node: the run parks. The node emits the item and parks the run as awaiting_input. Resolution re-enqueues the run with the decision output as the node result, routing approved / rejected / on_expired edges.
  • Task transition: the gate parks. The transition files the item and sets pending_transition on the task; no other transition may fire until it resolves. Approval fires the transition as the approval principal (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 via initiator_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's result; on rejection nothing executes. Expiry ends the chain unless the agent sets on_approval_expiry: "react", in which case the continuation carries { "decision": "expired" } (see Agents → Approval Expiry); the expired row, the approvals.expired event 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 its has_tool_call stop 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 (default rejected) selects the lifecycle state grouped.
  • min_count (default 2) 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 pending items to expired and emits approvals.expired.
  • The resolution path re-checks expires_at at decision time; an expired item returns 409 APPROVAL_EXPIRED and never executes.

Approve, reject, edit-then-approve​

  • Approve resolves the item and resumes its producer: an approval node routes down its approved edge (a downstream tool node 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 arguments on the approve call. They must be a JSON object satisfying the tool's parameters schema (400 APPROVAL_INVALID_EDIT otherwise); the original stays in proposed_action, the edit in edited_arguments. Editing needs more authority; see Who may resolve.
  • tool_context on 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 answers 400 INVALID_TOOL_CONTEXT_KEY and 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 | expired
  • resolved_by — resolving user's public ID; null on expiry
  • edited_args — null unless edit-then-approve
  • reason — required (non-null) on rejection
  • result — the executed tool output on approval for tool_call items; null for node items (execution belongs to the downstream tool node)

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:

ProposalAlso required to edit
any tooltools:CallTool on that tool
a builtin toolthe 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​

soat list-approvals --project-id proj_ABC --status pending

Approve (optionally with edited arguments)​

soat approve-approval --approval-id apr_01 --arguments '{"amount": 450}'

Reject with a reason​

soat reject-approval --approval-id apr_01 --reason "Exceeds monthly budget"