Approvals
The human decisions a run is waiting on, and the calls that let it continue.
Overview
An approval item is a frozen proposal: something an agent or an orchestration wants to do, held until a person says yes or no. Approving lets the paused work continue with the proposed action; rejecting stops it and records why.
Three things arrive with the proposal and matter more than the queue itself — the
agent's reasoning, its evidence, and its predicted_impact. They are the
whole point: a reviewer who has to reconstruct what the agent was trying to do
will approve on reflex, which is the same as having no gate.
This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.
Approvals are sold with guardrails and are part of the Pro plan and above. On a project whose billing
owner is on a lower rung, listing them and
listing recurrences answer
403 plan_feature_not_included with the plan and the feature in details,
(no formation resource type declares an approval — the runtime writes them). The plan is the project owner's, not the caller's.
An approval that already exists stays settleable on every rung. A generation
waiting on one is waiting on this project and on nothing else, so
GET,
POST …/approve and
POST …/reject answer whatever the plan —
otherwise a project that drops below the rung leaves the generation parked until
the runtime expires it. Its id reaches such a project through the
activity feed, which is on every rung — kind=approval_created
carries it in ref_id from the moment the approval is raised. Settling still
needs membership of the project; only the plan gate is lifted. Recurrences are
not included,
because a recurrence configures approvals still to come rather than settling one
that exists.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Approvals.
Data Model
ApprovalItem
| Field | Type | Description |
|---|---|---|
id | string | Public item ID (apr_ prefix). |
project_id | string | The owning project. |
origin | string | node, tool_call or task_transition — what produced it. Filtering and analytics only. |
status | string | pending, approved, rejected or expired. |
proposed_action | object, nullable | The frozen action. Null when the proposal is not a tool call — a task_transition item names its transition instead. |
reasoning | string, nullable | The proposing agent's rationale. |
evidence | object, nullable | Supporting structured data. |
predicted_impact | string, nullable | The expected effect of executing it. |
expires_at | string (date-time) | Hard gate — see Expiry is enforced, not advisory. |
dedup_key | string, nullable | Set on tool-call items to suppress duplicate proposals; also what groups a recurrence. |
orchestration_run_id | string, nullable | Originating orchestration run (node origin). |
node_id | string, nullable | Originating node in that run's graph. |
generation_id | string, nullable | Originating generation (tool_call origin). |
session_id | string, nullable | The session that generation ran in. |
agent_id | string, nullable | The proposing agent. |
task_id | string, nullable | The gated task (task_transition origin). |
task_transition | string, nullable | The transition that fires on approval. |
policy_version | string, nullable | The policy version in force when it was filed. |
previous_item_id | string, nullable | The prior item, when this proposal was re-filed after a matching one was rejected. |
resolved_by | string, nullable | The deciding user's public ID. Null on expiry. |
resolution_reason | string, nullable | Required on rejection. |
edited_arguments | object, nullable | Set when the decision was edit-then-approve. |
created_at | string (date-time) | |
updated_at | string (date-time) |
ApprovalRecurrenceGroup
| Field | Type | Description |
|---|---|---|
dedup_key | string | The shared key that defines the group. |
agent_id | string, nullable | The proposing agent, shared across the group. |
tool_id | string, nullable | The proposed tool, shared across the group. |
count | integer | Items in the group. |
chain | array of ApprovalItem | The items oldest → newest, following previous_item_id. |
reasons | array of string | The chain's resolution reasons, in order. |
Key Concepts
Where the items come from
origin says which producer filed the item, and it also tells you which of the
correlation fields are populated:
tool_call— an agent proposed a tool call that needs sign-off. Carriesgeneration_id,session_id,agent_idand aproposed_action. Gate a tool with guardrails approves one.node— an orchestration reached a node that requires approval. Carriesorchestration_run_idandnode_id. Pause a run for a human decision approves one.task_transition— a task transition is gated. Carriestask_idandtask_transition, and noproposed_action.
Edit, then approve
POST /v1/projects/{project_id}/approvals/{approval_id}/approve
takes optional arguments that replace what the agent proposed. The original
stays on the item and the replacement is recorded as edited_arguments, so the
diff between what was asked for and what was allowed is part of the record rather
than lost in a decision.
Rejecting
requires a reason, for the same reason: the next reviewer of the same
recurring proposal needs to know why the last one said no.
Expiry is enforced, not advisory
expires_at is re-checked at decision time. An item past it can never be
approved — the answer is a 409, not a late success. That is what makes a stalled
queue safe to leave alone: nothing waits indefinitely for a person who has stopped
looking.
What keeps coming back
GET /v1/projects/{project_id}/approvals/recurrences
groups items by dedup_key and returns those that recur at least min_count
times, most-recurrent first, each with its chain and the reasons given along it.
It answers a different question from the queue: not "what needs deciding" but "what am I deciding over and over". A pattern that has been rejected the same way five times is a rule waiting to be written, not a decision worth making a sixth time. Grouping is by exact key — no clustering — so it reports repetition rather than guessing at similarity.
Who may do what
Every route needs any project member. The deciding user is recorded on the item.
Examples
Read the pending queue
- CLI
- SDK
- curl
naturali list-approvals \
--project-id proj_V1StGXR8Z5jdHi6B \
--status pending \
--limit 20
const { data: queue } = await naturali.approvals.listApprovals({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { status: 'pending', limit: 20 },
});
for (const item of queue?.data ?? []) {
console.log(item.id, item.predicted_impact, item.expires_at);
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/approvals?status=pending&limit=20" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Read one proposal in full
- CLI
- SDK
- curl
naturali get-approval \
--project-id proj_V1StGXR8Z5jdHi6B \
--approval-id apr_V1StGXR8Z5jdHi6B
const { data: item } = await naturali.approvals.getApproval({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
approval_id: 'apr_V1StGXR8Z5jdHi6B',
},
});
console.log(item?.reasoning, item?.evidence, item?.proposed_action);
curl https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/approvals/apr_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN"
Approve it, with a smaller amount than proposed
- CLI
- SDK
- curl
naturali approve-approval \
--project-id proj_V1StGXR8Z5jdHi6B \
--approval-id apr_V1StGXR8Z5jdHi6B \
--arguments '{ "amount": 250 }'
const { data: item } = await naturali.approvals.approveApproval({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
approval_id: 'apr_V1StGXR8Z5jdHi6B',
},
body: { arguments: { amount: 250 } },
});
console.log(item?.status, item?.edited_arguments);
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/approvals/apr_V1StGXR8Z5jdHi6B/approve \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "arguments": { "amount": 250 } }'
Reject it, on the record
- CLI
- SDK
- curl
naturali reject-approval \
--project-id proj_V1StGXR8Z5jdHi6B \
--approval-id apr_V1StGXR8Z5jdHi6B \
--reason "Refunds over 200 go through finance."
const { data: item } = await naturali.approvals.rejectApproval({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
approval_id: 'apr_V1StGXR8Z5jdHi6B',
},
body: { reason: 'Refunds over 200 go through finance.' },
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/approvals/apr_V1StGXR8Z5jdHi6B/reject \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "Refunds over 200 go through finance." }'
Find what you keep deciding
- CLI
- SDK
- curl
naturali list-approval-recurrences \
--project-id proj_V1StGXR8Z5jdHi6B \
--min-count 3
const { data: groups } =
await naturali.approvals.listApprovalRecurrences({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { min_count: 3 },
});
for (const group of groups?.data ?? []) {
console.log(group.count, group.dedup_key, group.reasons);
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/approvals/recurrences?min_count=3" \
-H "Authorization: Bearer $NATURALI_TOKEN"