Approvals
A centralized queue of human decisions. When an agent proposes a risky action, the platform files an approval item carrying the frozen proposed action, the supporting evidence, and a hard expiry — then a human approves, edits-then-approves, or rejects it.
Overview
Approvals are producer-agnostic: anything that can propose a risky action files into the same queue, with one item model, one expiry enforcement path, and one decision output shape.
Items are created by the platform only — there is no public create endpoint. Three producers file items today:
- the
approvalorchestration node — declarative placement in a DAG (origin: node); - tool-call interception — a guardrail attached to a
project, agent, or tool gates tool calls on every execution surface: chat
sessions, direct generations, MCP (
origin: tool_call); - approval-gated task transitions — a workflow transition declaring
requires_approvalparks a task move behind an approval (origin: task_transition). The item carries noproposed_action; it gates the transition named bytask_transitionontask_id.
The origin field records which producer filed an item, for analytics and
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) — an item raised by a
requires_approvalworkflow transition, where the guard is re-evaluated at resolution time.
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
All of an item's evidence (proposed_action, reasoning, evidence,
predicted_impact) is resolved against run/call state at emit time and frozen
onto the item. Later state changes never alter what the approver sees — a decision
is made on exactly the evidence the agent had.
How producers suspend and resume
The two producers share the item lifecycle but suspend differently:
approvalnode — the run parks. Orchestration runs are durable: 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. A
requires_approvaltransition files the item and setspending_transitionon the task; the task keeps its state and no other transition may fire until the item 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. A synchronous generation cannot
be held open for hours. 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 normally (the model reads the result and closes with "queued for your approval"). On resolution, the platform starts a continuation generation — linked to the original viainitiator_generation_id— feeding the decision output back into the agent's context. On approval the platform first executes the frozen (or edited) arguments and includes the tool's output as the decision'sresult; on rejection nothing executes and the continuation carries the decision. An expiry ends the chain instead of continuing it, unless the agent setson_approval_expiry: "react"— nobody was at the wheel, so there is nobody to report to, and theexpiredrow, theapprovals.expiredevent and the auto-filed exception are already the whole record. See Agents → Approval Expiry; a reacting agent's continuation carries{ "decision": "expired" }, the exact counterpart of the node path'son_expirededge. When the original generation ran in a session or conversation, the continuation's messages append there. - The continuation runs the agent's own config. That includes
tool_choice: an agent that forces a tool reports the decision by reaching its declaredhas_tool_callstop condition, which is why that condition is mandatory for a forcing agent rather than optional.
Continuation identity
A continuation runs as the principal that started the chain — never as the approver. The approver decided whether the proposed action happens, not as whom; acting as them would silently widen the chain to that person's access.
Because an item can sit pending for days, identity comes from the row rather
than from the request that resolved it: 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 from it. That token is what the
continuation's builtin tools authenticate with, and what the
approved action itself executes with. It asserts identity only — authorization
is still evaluated per request, so a chain a scoped API key started can never
reach past that key's policies, and revoking the key stops the chain even
mid-flight.
The continuation records the same principal on its own generation, so a further approval in the same chain re-mints from there in turn, however many hops later. A chain with no recorded principal — one started by a trigger or an OAuth token, which carry their boundary in the token rather than in the principal — gets no credential, and its self-calls stay unauthenticated.
Duplicate proposals (dedup)
An agent retrying a proposal must not spam the queue. Tool-call items carry a
dedup_key derived from the proposing agent, tool, action, and resolved
arguments: while a matching item is pending, a duplicate emit files nothing
and returns the existing item — the agent's tool result carries the existing
approval_id. Once the item resolves (approved, rejected, or expired), the
same proposal files a fresh item. Node-produced items are not deduplicated —
each run pauses exactly once per approval node.
When the fresh item follows a rejected one with the same dedup_key, it is
admitted rather than suppressed and its previous_item_id links back to that
rejected item, so approvers see the recurrence.
Recurrence view
GET /api/v1/approvals/recurrences is a read-only rollup answering "what
keeps coming back?". It groups items by dedup_key and returns those recurring
at least min_count times (default 2), most-recurrent first. Each group
carries the agent_id, tool_id, count, the ordered item chain (the
previous_item_id thread, oldest → newest), and the reasons in order.
status(defaultrejected) selects the lifecycle state groups are built from — recurring rejections are the primary signal.min_count(default2) is the floor for a group to be returned.- Grouping is exact-key only — no semantic clustering.
A recurring correction has two durable homes: a guardrail
deny (it must never happen again) or the agent's instructions
(agent versions archive every
write). It is not a fact about the world, so it does not belong in
memories.
Expiry is a hard gate
Evidence goes stale, so 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, closing the sweep-vs-approve race. An expired item can never be approved or executed — even a click a millisecond after expiry returns409 APPROVAL_EXPIRED.
Approve, reject, edit-then-approve
- Approve resolves the item and resumes its producer with the decision — an
approvalorchestration node routes down itsapprovededge (where a downstreamtoolnode acts on the frozen or edited arguments); a tool-call item has its frozen or edited arguments executed by the platform, and the result flows into the continuation generation. - Edit-then-approve replaces the arguments via the
argumentsfield on the approve call. Edited arguments must be a JSON object and must satisfy the tool's ownparametersschema (400 APPROVAL_INVALID_EDITotherwise); the original proposal is preserved inproposed_action, and the edit is recorded inedited_arguments. Editing also takes more authority than approving — see Who may resolve. - Reject requires a
reason, preserved on the item.
Decision output
Resolution produces a producer-agnostic decision artifact — the approval
orchestration node consumes it as its node result; a tool-call continuation
consumes it as the tool result. Identical shape for both:
{
"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 the platform executes the frozen (or edited) arguments at resolution time and populates it; fornodeitems execution belongs to the downstreamtoolnode, so it staysnullin the node result
Who may resolve
Any principal with approvals:ResolveApproval in the project may resolve any of
the project's items. There is no per-item targeting or assignment — the guardrail
policy decides what needs a human, and the project policy layer decides who
counts as one. Per-approver routing is a deferred future phase.
Editing the arguments takes more than resolving. Approving as proposed adjudicates a call somebody else's agent composed; editing composes a new one, and the approved action executes under the proposing generation's principal rather than the approver's. So 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 |
The second row is there because a builtin action is dispatched in-process, where
the route re-checks it against whichever credential is on the request — the
proposer's. Nothing else on that path asks whether the approver could have
performed it. An edit that fails either check answers 403 FORBIDDEN; approving
the same item 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"}'