Skip to main content

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:

  1. Create the tool that sends the reply.
  2. Add the approval step.
  3. Start a run — it waits.
  4. Read the proposal.
  5. Approve it.
  6. 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_cT9LACJi0WypPf5U
    export ORCHESTRATION=orch_avQnhq18EzeQQPWh
  • jq, to extend the graph in step 2.

Included from the Pro plan

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.

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"}'
{
"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:

  • approve is an approval node. It freezes the proposed call — the tool and its arguments, resolved from the run's state — onto an approval item, and parks the run until someone decides. It does not call the tool itself. instructions is what the approver reads; reasoning is why the step exists.
  • send is a tool node that calls send-reply with 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.

naturali update-orchestration \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION" \
--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.

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."
}'
{
"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.

naturali get-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL"
{
"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.

naturali approve-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL"
{
"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.

Rejecting instead

POST …/reject requires a reason (Reject it, on the record):

naturali reject-approval \
--project-id "$PROJECT" \
--approval-id "$APPROVAL" \
--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​

naturali get-orchestration-run \
--project-id "$PROJECT" \
--orchestration-run-id "$RUN"
{
"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_input to succeeded, with required_action cleared.
  • The approve step records "decision": "approved" and who made it.
  • send ran after it, with the reviewed reply as its input — httpbin.org echoed it back in json. Before the approval, nothing had been sent.

What's next​