Pause a run for a human decision
By the end of this tutorial you will have an
orchestration that holds every reply until a
person approves it — proven by a run that waits as awaiting_input, an
approval you grant, and a send step that runs only
after it.
Six steps:
- Create the tool that sends the reply.
- Add the approval step.
- Start a run — it waits.
- Read the proposal.
- Approve it.
- Read the run back — the proof.
Every call is shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
-
The support-reply orchestration from Orchestrate several agents, with these exported:
export NATURALI_TOKEN=nat_sk_...export PROJECT=proj_cT9LACJi0WypPf5Uexport ORCHESTRATION=orch_avQnhq18EzeQQPWh -
jq, to extend the graph in step 2.
Approvals are part of the Pro plan and above. On
a lower rung, listing approvals answers 403 plan_feature_not_included.
Reading, approving and rejecting an approval that already exists answer on
every plan, so a run is never left waiting on a decision nobody may make. The
plan is the project owner's, not the caller's — see
Pricing.
1. Create the tool that sends the reply
An approval step reviews a proposed call to a tool. This
http tool sends the reply. It posts to httpbin.org, which echoes the request
back, so nothing reaches a customer — in your system this is the endpoint that
delivers the message.
- CLI
- SDK
- curl
naturali create-tool \
--project-id "$PROJECT" \
--name send-reply \
--type http \
--description 'Sends a reply to the customer.' \
--parameters '{"type":"object","properties":{"reply":{"type":"string"}},"required":["reply"]}' \
--execute '{"url":"https://httpbin.org/post","method":"POST"}'
const { data: tool } = await naturali.tools.createTool({
path: { project_id: process.env.PROJECT! },
body: {
name: 'send-reply',
type: 'http',
description: 'Sends a reply to the customer.',
parameters: {
type: 'object',
properties: { reply: { type: 'string' } },
required: ['reply'],
},
execute: { url: 'https://httpbin.org/post', method: 'POST' },
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/tools" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "send-reply",
"type": "http",
"description": "Sends a reply to the customer.",
"parameters": {
"type": "object",
"properties": { "reply": { "type": "string" } },
"required": ["reply"]
},
"execute": { "url": "https://httpbin.org/post", "method": "POST" }
}'
{
"id": "tool_0WdZqOGBrRPdEPS6",
"project_id": "proj_cT9LACJi0WypPf5U",
"type": "http",
"name": "send-reply",
"description": "Sends a reply to the customer.",
"parameters": {
"type": "object",
"required": ["reply"],
"properties": { "reply": { "type": "string" } }
},
"execute": { "url": "https://httpbin.org/post", "method": "POST" }
}
export SEND_TOOL=tool_0WdZqOGBrRPdEPS6
2. Add the approval step
Two nodes go after review:
approveis anapprovalnode. It freezes the proposed call — the tool and itsarguments, resolved from the run's state — onto an approval item, and parks the run until someone decides. It does not call the tool itself.instructionsis what the approver reads;reasoningis why the step exists.sendis atoolnode that callssend-replywith the reviewed reply.
The edge into send carries "condition": "approved", so it is followed only
on an approval. Take the current graph and append both:
CURRENT=$(curl -s "https://api.naturali.ai/v1/projects/$PROJECT/orchestrations/$ORCHESTRATION" \
-H "Authorization: Bearer $NATURALI_TOKEN")
NODES=$(printf '%s' "$CURRENT" | jq --arg tool "$SEND_TOOL" '.nodes + [
{
"id": "approve",
"type": "approval",
"tool_id": $tool,
"arguments": { "reply": { "var": "reply" } },
"reasoning": "Every reply is checked by a person before it reaches the customer.",
"instructions": "Approve to send this reply as written, or reject with a reason.",
"expires_in": 3600
},
{
"id": "send",
"type": "tool",
"tool_id": $tool,
"input_mapping": { "reply": { "var": "reply" } },
"state_mapping": { "sent": { "var": "output" } }
}
]')
EDGES=$(printf '%s' "$CURRENT" | jq '.edges + [
{ "from": "review", "to": "approve" },
{ "from": "approve", "to": "send", "condition": "approved" }
]')
Then save it. PATCH /v1/projects/{project_id}/orchestrations/{orchestration_id}
replaces nodes and edges and archives version 1; a run already in flight
keeps the version it started on.
- CLI
- SDK
- curl
naturali update-orchestration \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION" \
--nodes "$NODES" \
--edges "$EDGES"
const { data: current } = await naturali.orchestrations.getOrchestration({
path: {
project_id: process.env.PROJECT!,
orchestration_id: process.env.ORCHESTRATION!,
},
});
const { data: updated } = await naturali.orchestrations.updateOrchestration({
path: {
project_id: process.env.PROJECT!,
orchestration_id: process.env.ORCHESTRATION!,
},
body: {
nodes: [
...current!.nodes,
{
id: 'approve',
type: 'approval',
tool_id: tool!.id,
arguments: { reply: { var: 'reply' } },
reasoning:
'Every reply is checked by a person before it reaches the customer.',
instructions:
'Approve to send this reply as written, or reject with a reason.',
expires_in: 3600,
},
{
id: 'send',
type: 'tool',
tool_id: tool!.id,
input_mapping: { reply: { var: 'reply' } },
state_mapping: { sent: { var: 'output' } },
},
],
edges: [
...current!.edges,
{ from: 'review', to: 'approve' },
{ from: 'approve', to: 'send', condition: 'approved' },
],
},
});
curl -X PATCH "https://api.naturali.ai/v1/projects/$PROJECT/orchestrations/$ORCHESTRATION" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"nodes\": $NODES, \"edges\": $EDGES }"
{
"id": "orch_avQnhq18EzeQQPWh",
"project_id": "proj_cT9LACJi0WypPf5U",
"name": "support-reply",
"version": 2,
"edges": [
{ "from": "research", "to": "draft" },
{ "from": "draft", "to": "review" },
{ "from": "review", "to": "approve" },
{ "from": "approve", "to": "send", "condition": "approved" }
],
"updated_at": "2026-10-03T09:34:03.246Z"
}
(nodes left out.)
3. Start a run — it waits
Start a run with "wait": true.
The request is held open until the run settles or parks, so it returns as soon as the three agents have written the reply
and the approve step is waiting.
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION" \
--wait \
--input '{
"question": "Can I get a refund on a cake I picked up yesterday?",
"policy": "Custom cakes: refunds within 48 hours of pickup, with the receipt. Bread and pastries: no refunds, same-day exchanges only. Refunds go back to the original payment method within 5 business days."
}'
const { data: run } = await naturali.orchestrations.startOrchestrationRun({
path: { project_id: process.env.PROJECT! },
body: {
orchestration_id: process.env.ORCHESTRATION!,
wait: true,
input: {
question: 'Can I get a refund on a cake I picked up yesterday?',
policy:
'Custom cakes: refunds within 48 hours of pickup, with the receipt. Bread and pastries: no refunds, same-day exchanges only. Refunds go back to the original payment method within 5 business days.',
},
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/orchestration-runs" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"orchestration_id\": \"$ORCHESTRATION\",
\"wait\": true,
\"input\": {
\"question\": \"Can I get a refund on a cake I picked up yesterday?\",
\"policy\": \"Custom cakes: refunds within 48 hours of pickup, with the receipt. Bread and pastries: no refunds, same-day exchanges only. Refunds go back to the original payment method within 5 business days.\"
}
}"
{
"id": "orch_run_ZEDKoD4bqvdEyFXH",
"orchestration_version": 2,
"status": "awaiting_input",
"active_nodes": ["approve"],
"required_action": {
"type": "approval",
"node_id": "approve",
"prompt": "Approve to send this reply as written, or reject with a reason.",
"context": {
"reply": "Yes, custom cakes are eligible for refunds, but you must request one within 48 hours of pickup. Since it has been a day, your request is within the timeframe if you make it now."
},
"approval_id": "apr_KJkOcEAZOXkkNmIj",
"expires_at": "2026-10-03T10:34:12.371Z"
},
"output": null
}
The run is awaiting_input, and
required_action says why: an approval, at
the approve node, with the reply it proposes to send. Nothing has been sent.
The item expires an hour later (expires_in: 3600); left alone, it
can never be approved after that.
export RUN=orch_run_ZEDKoD4bqvdEyFXH
export APPROVAL=apr_KJkOcEAZOXkkNmIj
4. Read the proposal
The approver sees the item, not the run. It holds the frozen call — which tool,
with which arguments — and the
run and node that filed it. A reviewer who does
not hold the id finds it in the queue with
GET /v1/projects/{project_id}/approvals?status=pending.
- CLI
- SDK
- curl
naturali get-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL"
const { data: item } = await naturali.approvals.getApproval({
path: {
project_id: process.env.PROJECT!,
approval_id: run!.required_action!.approval_id!,
},
});
curl "https://api.naturali.ai/v1/projects/$PROJECT/approvals/$APPROVAL" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "apr_KJkOcEAZOXkkNmIj",
"project_id": "proj_cT9LACJi0WypPf5U",
"origin": "node",
"status": "pending",
"proposed_action": {
"tool_id": "tool_0WdZqOGBrRPdEPS6",
"arguments": {
"reply": "Yes, custom cakes are eligible for refunds, but you must request one within 48 hours of pickup. Since it has been a day, your request is within the timeframe if you make it now."
}
},
"reasoning": "Every reply is checked by a person before it reaches the customer.",
"expires_at": "2026-10-03T10:34:12.371Z",
"orchestration_run_id": "orch_run_ZEDKoD4bqvdEyFXH",
"node_id": "approve",
"resolved_by": null
}
5. Approve it
POST /v1/projects/{project_id}/approvals/{approval_id}/approve
settles the item and hands the run back. It follows the approved edge into
send.
- CLI
- SDK
- curl
naturali approve-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL"
const { data: decided } = await naturali.approvals.approveApproval({
path: {
project_id: process.env.PROJECT!,
approval_id: item!.id,
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/approvals/$APPROVAL/approve" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "apr_KJkOcEAZOXkkNmIj",
"status": "approved",
"resolved_by": "user_7x9yZtYrtTbPXbx0",
"edited_arguments": null,
"updated_at": "2026-10-03T09:34:28.513Z"
}
resolved_by records who decided. To change the reply before it goes out, pass
the edited call instead — --arguments '{"reply": "…"}', or
body: { arguments: { reply: '…' } } — and the item
keeps both versions.
POST …/reject requires a reason
(Reject it, on the record):
- CLI
- SDK
- curl
naturali reject-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL" \
--reason 'Offer the same-day exchange instead.'
await naturali.approvals.rejectApproval({
path: { project_id: process.env.PROJECT!, approval_id: item!.id },
body: { reason: 'Offer the same-day exchange instead.' },
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/approvals/$APPROVAL/reject" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "Offer the same-day exchange instead." }'
The run then finishes succeeded without calling the tool: send is recorded
as skipped, and the approve step's output carries "decision": "rejected"
with the reason. Its output.reply still holds the reply nobody sent, so a
caller reads the decision rather than assuming a reply went out. To do something
else on a rejection, add an edge with "condition": "rejected" — or
"expired" for an item nobody settled in time.
6. Read the run back
- CLI
- SDK
- curl
naturali get-orchestration-run \
--project-id "$PROJECT" \
--orchestration-run-id "$RUN"
const { data: finished } = await naturali.orchestrations.getOrchestrationRun({
path: {
project_id: process.env.PROJECT!,
orchestration_run_id: run!.id,
},
});
curl "https://api.naturali.ai/v1/projects/$PROJECT/orchestration-runs/$RUN" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "orch_run_ZEDKoD4bqvdEyFXH",
"status": "succeeded",
"required_action": null,
"output": {
"reply": "Yes, custom cakes are eligible for refunds, but you must request one within 48 hours of pickup. Since it has been a day, your request is within the timeframe if you make it now."
},
"node_executions": [
{
"node_id": "approve",
"node_type": "approval",
"status": "completed",
"output": {
"decision": "approved",
"approvalId": "apr_KJkOcEAZOXkkNmIj",
"resolvedBy": "user_7x9yZtYrtTbPXbx0",
"editedArgs": null,
"reason": null
}
},
{
"node_id": "send",
"node_type": "tool",
"status": "completed",
"input": {
"reply": "Yes, custom cakes are eligible for refunds, but you must request one within 48 hours of pickup. Since it has been a day, your request is within the timeframe if you make it now."
},
"output": {
"url": "https://httpbin.org/post",
"json": {
"reply": "Yes, custom cakes are eligible for refunds, but you must request one within 48 hours of pickup. Since it has been a day, your request is within the timeframe if you make it now."
}
}
}
],
"usage": {
"cost_usd": 0.00006205,
"input_tokens": 275,
"output_tokens": 107
},
"completed_at": "2026-10-03T09:34:28.700Z"
}
(node_executions trimmed to the last two; the three agent steps come first.)
That is the proof:
- The run went from
awaiting_inputtosucceeded, withrequired_actioncleared. - The
approvestep records"decision": "approved"and who made it. sendran after it, with the reviewed reply as its input —httpbin.orgechoed it back injson. Before the approval, nothing had been sent.
What's next
- Ask a person for an answer, not a yes/no — a
humannode parks the run with apromptand optionaloptions, answered throughPOST /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id}/human-input; see A paused run tells you what it wants. - Hold only the risky calls — a guardrail decides per call whether an agent's tool call needs a person, in Gate a tool with guardrails.
- Find what keeps getting rejected — recurring proposals and the reasons given, in Approvals.