Orchestrate several agents
By the end of this tutorial you will have a support-reply orchestration of three agents, each doing one job in order — proven by a run that succeeded and whose record shows the facts, the draft and the checked reply each step produced.
Five steps:
- Create the three agents.
- Describe the pipeline and validate it.
- Create the orchestration.
- Start a run.
- 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
- A credential exported as
NATURALI_TOKEN, a project id asPROJECTand a working AI provider id asPROVIDER— from Your first agent generation. - A client set up as in that tutorial.
export NATURALI_TOKEN=nat_sk_...
export PROJECT=proj_V1StGXR8Z5jdHi6B
export PROVIDER=aip_V1StGXR8Z5jdHi6B
Every agent node in a run is one generation, so this pipeline counts as three runs against your plan's monthly allowance, and its managed tokens draw on the balance. A run never starts while the project owes for usage already served — see A negative balance stops a run before it starts.
1. Create the three agents
Each agent does one job and reads only what the step before it handed over. An
agent node receives its inputs as one user message, one key: value line per
input, so the instructions name the inputs they expect.
The same call three times, with a different name and instructions:
- CLI
- SDK
- curl
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name support-researcher \
--instructions 'You receive a customer question and a policy. List, as short bullet points, only the policy facts that answer the question.'
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name support-writer \
--instructions 'You receive a customer question and a list of facts. Write a friendly reply of at most three sentences that uses only those facts.'
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name support-reviewer \
--instructions 'You receive facts and a draft reply. Return the draft, corrected so it states nothing the facts do not support. Return only the reply text, without quotes.'
const roles = {
'support-researcher':
'You receive a customer question and a policy. List, as short bullet points, only the policy facts that answer the question.',
'support-writer':
'You receive a customer question and a list of facts. Write a friendly reply of at most three sentences that uses only those facts.',
'support-reviewer':
'You receive facts and a draft reply. Return the draft, corrected so it states nothing the facts do not support. Return only the reply text, without quotes.',
};
const agents: Record<string, string> = {};
for (const [name, instructions] of Object.entries(roles)) {
const { data } = await naturali.agents.createAgent({
path: { project_id: process.env.PROJECT! },
body: { ai_provider_id: process.env.PROVIDER!, name, instructions },
});
agents[name] = data!.id;
}
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ai_provider_id\": \"$PROVIDER\",
\"name\": \"support-researcher\",
\"instructions\": \"You receive a customer question and a policy. List, as short bullet points, only the policy facts that answer the question.\"
}"
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ai_provider_id\": \"$PROVIDER\",
\"name\": \"support-writer\",
\"instructions\": \"You receive a customer question and a list of facts. Write a friendly reply of at most three sentences that uses only those facts.\"
}"
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ai_provider_id\": \"$PROVIDER\",
\"name\": \"support-reviewer\",
\"instructions\": \"You receive facts and a draft reply. Return the draft, corrected so it states nothing the facts do not support. Return only the reply text, without quotes.\"
}"
Each call answers with the agent; the first:
{
"id": "agent_lBSeFLo1Z4NgQlm0",
"project_id": "proj_cT9LACJi0WypPf5U",
"ai_provider_id": "aip_CLcDNRe8GpP1pBNs",
"name": "support-researcher",
"version": 1
}
export RESEARCHER=agent_lBSeFLo1Z4NgQlm0
export WRITER=agent_VOJ1cafUEXZnR8ut
export REVIEWER=agent_iKQR3K4ivAFcVSzC
2. Describe the pipeline and validate it
A pipeline is nodes (what each step does) and edges (what follows what).
Each node here is an agent node:
input_mappingsays what the agent receives. Each value is JSON Logic over the run's state:input.questionis the run's input,factsis what an earlier node wrote.state_mappingsays where the agent's answer goes.output.contentis the agent's text reply.
So the researcher writes facts, the writer reads facts and writes draft,
and the reviewer reads both and writes reply.
NODES=$(cat <<EOF
[
{
"id": "research",
"type": "agent",
"agent_id": "$RESEARCHER",
"input_mapping": {
"question": { "var": "input.question" },
"policy": { "var": "input.policy" }
},
"state_mapping": { "facts": { "var": "output.content" } }
},
{
"id": "draft",
"type": "agent",
"agent_id": "$WRITER",
"input_mapping": {
"question": { "var": "input.question" },
"facts": { "var": "facts" }
},
"state_mapping": { "draft": { "var": "output.content" } }
},
{
"id": "review",
"type": "agent",
"agent_id": "$REVIEWER",
"input_mapping": {
"facts": { "var": "facts" },
"draft": { "var": "draft" }
},
"state_mapping": { "reply": { "var": "output.content" } }
}
]
EOF
)
EDGES='[{ "from": "research", "to": "draft" }, { "from": "draft", "to": "review" }]'
POST /v1/projects/{project_id}/orchestrations/validate
checks the graph without saving it
— an edge to a node that does not exist, a var that no step writes, a node missing a field its type needs.
- CLI
- SDK
- curl
naturali validate-orchestration \
--project-id "$PROJECT" \
--nodes "$NODES" \
--edges "$EDGES"
const agentNode = (
id: string,
agent_id: string,
input_mapping: Record<string, unknown>,
writes: string
) => {
return {
id,
type: 'agent' as const,
agent_id,
input_mapping,
state_mapping: { [writes]: { var: 'output.content' } },
};
};
const graph = {
nodes: [
agentNode(
'research',
agents['support-researcher'],
{ question: { var: 'input.question' }, policy: { var: 'input.policy' } },
'facts'
),
agentNode(
'draft',
agents['support-writer'],
{ question: { var: 'input.question' }, facts: { var: 'facts' } },
'draft'
),
agentNode(
'review',
agents['support-reviewer'],
{ facts: { var: 'facts' }, draft: { var: 'draft' } },
'reply'
),
],
edges: [
{ from: 'research', to: 'draft' },
{ from: 'draft', to: 'review' },
],
};
const { data: check } = await naturali.orchestrations.validateOrchestration({
path: { project_id: process.env.PROJECT! },
body: graph,
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/orchestrations/validate" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"nodes\": $NODES, \"edges\": $EDGES }"
{ "valid": true, "errors": [], "warnings": [] }
3. Create the orchestration
Saving the graph makes it version 1.
output_mapping decides what a finished
run's output holds — here just the reviewed reply — so a caller reads
output.reply however the steps inside change later.
- CLI
- SDK
- curl
naturali create-orchestration \
--project-id "$PROJECT" \
--name support-reply \
--nodes "$NODES" \
--edges "$EDGES" \
--output-mapping '{ "reply": { "var": "state.reply" } }'
const { data: orchestration } =
await naturali.orchestrations.createOrchestration({
path: { project_id: process.env.PROJECT! },
body: {
name: 'support-reply',
...graph,
output_mapping: { reply: { var: 'state.reply' } },
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/orchestrations" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"support-reply\",
\"nodes\": $NODES,
\"edges\": $EDGES,
\"output_mapping\": { \"reply\": { \"var\": \"state.reply\" } }
}"
{
"id": "orch_HGy16aPwsrkszumb",
"project_id": "proj_cT9LACJi0WypPf5U",
"name": "support-reply",
"version": 1,
"nodes": [
{
"id": "research",
"type": "agent",
"agent_id": "agent_lBSeFLo1Z4NgQlm0",
"input_mapping": {
"policy": { "var": "input.policy" },
"question": { "var": "input.question" }
},
"state_mapping": { "facts": { "var": "output.content" } }
}
],
"edges": [
{ "from": "research", "to": "draft" },
{ "from": "draft", "to": "review" }
],
"output_mapping": { "reply": { "var": "state.reply" } },
"created_at": "2026-10-03T08:40:17.262Z"
}
(nodes trimmed to the first.)
export ORCHESTRATION=orch_HGy16aPwsrkszumb
4. Start a run
A run takes the input the first node reads: the customer's question and the
policy to answer it from. It is
background by default — the call answers
201 with a queued run at once.
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION" \
--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: orchestration!.id,
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\",
\"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_6edpfLYy6vJtHfgH",
"orchestration_id": "orch_HGy16aPwsrkszumb",
"orchestration_version": 1,
"status": "queued",
"active_nodes": [],
"output": null,
"started_at": "2026-10-03T08:40:25.039Z"
}
export RUN=orch_run_6edpfLYy6vJtHfgH
Pass "wait": true in the body instead to hold the request open until the run
settles.
5. Read the run back
Poll the run until status is terminal — succeeded, failed, cancelled
or expired. This one took about three seconds.
- CLI
- SDK
- curl
naturali get-orchestration-run \
--project-id "$PROJECT" \
--orchestration-run-id "$RUN"
const terminal = ['succeeded', 'failed', 'cancelled', 'expired'];
let latest = run;
while (!terminal.includes(latest!.status)) {
await new Promise((resolve) => {
return setTimeout(resolve, 2000);
});
({ data: latest } = 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_6edpfLYy6vJtHfgH",
"status": "succeeded",
"output": {
"reply": "Yes, a cake is eligible for a refund. It must be requested within 48 hours of pickup, and a receipt is required. Please note that refunds are processed back to the original payment method."
},
"node_executions": [
{
"node_id": "research",
"node_type": "agent",
"status": "completed",
"output": {
"object": null,
"content": "* Cakes are eligible for refunds.\n* Refunds must be requested within 48 hours of pickup.\n* A receipt is required.\n* Refunds are processed back to the original payment method."
}
},
{
"node_id": "draft",
"node_type": "agent",
"status": "completed",
"input": {
"facts": "* Cakes are eligible for refunds.\n* Refunds must be requested within 48 hours of pickup.\n* A receipt is required.\n* Refunds are processed back to the original payment method.",
"question": "Can I get a refund on a cake I picked up yesterday?"
},
"output": {
"object": null,
"content": "Yes, a cake is eligible for a refund, but it must be requested within 48 hours of pickup, and a receipt is required. Please note that refunds are processed back to the original payment method."
}
},
{
"node_id": "review",
"node_type": "agent",
"status": "completed",
"output": {
"object": null,
"content": "Yes, a cake is eligible for a refund. It must be requested within 48 hours of pickup, and a receipt is required. Please note that refunds are processed back to the original payment method."
}
}
],
"usage": {
"cost_usd": 0.00007313,
"input_tokens": 319,
"output_tokens": 127
},
"trace_id": "trace_2mWssjSFsGAd5d2D",
"completed_at": "2026-10-03T08:40:28.399Z"
}
(node_executions trimmed; each also carries its input and timings.)
That is the proof:
statusissucceededandoutput.replyis the reviewed answer, shaped byoutput_mapping.node_executionslistsresearch,draftandreviewin order, eachcompleted, with the input it received and the output it produced — the writer's input is exactly the researcher's facts.usageis the cost of all three steps together, andtrace_idopens the same run in traces.
What's next
- Branch an orchestration — a
conditionnode and conditionaledgessend each message down one path. - Pause a run for a human decision — extend this orchestration so every reply waits for a person's approval.
- Run it on a schedule — a trigger can target an orchestration the way Run an agent on a schedule targets an agent.
- Change a step safely — editing the orchestration archives the previous version, and a run in flight keeps the version it started on; see Versioning.