Branch an orchestration
By the end of this tutorial you will have an
orchestration that decides its own path: a
triage agent classifies each customer message, and a condition node sends it
to the refunds agent or the general agent, never both — proven by two runs whose
records show the branch that ran as completed and the other as skipped.
Five steps:
- Create the three agents.
- Describe the branching graph and validate it.
- Create the orchestration.
- Run a refund request.
- Run a general question — 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. - How
nodes,edges,input_mappingandstate_mappingfit together, from Orchestrate several agents. This tutorial reuses them without repeating the explanation.
export NATURALI_TOKEN=nat_sk_...
export PROJECT=proj_V1StGXR8Z5jdHi6B
export PROVIDER=aip_V1StGXR8Z5jdHi6B
Each agent node that executes is one generation, so each run here counts as two runs against your plan's monthly allowance — the triage and the one reply. A skipped node generates nothing and costs nothing.
1. Create the three agents
The triage agent answers with a structured category rather than prose: its
output_schema limits it to refund or other, so the branch decision reads a
field, not a sentence (see Structured output). The
other two each answer one kind of message.
- CLI
- SDK
- curl
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name support-triage \
--instructions 'You receive a customer message. Classify it: refund if the customer asks for their money back, other for anything else.' \
--output-schema '{
"type": "object",
"properties": { "category": { "type": "string", "enum": ["refund", "other"] } },
"required": ["category"]
}'
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name support-refunds \
--instructions 'You answer refund requests for a bakery. Refunds: custom cakes within 48 hours of pickup with the receipt; bread and pastries are exchange only, same day. Reply in at most two sentences.'
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name support-general \
--instructions 'You answer general questions for a bakery open 7am to 6pm, Monday to Saturday. Reply in at most two sentences.'
const create = async (body: Record<string, unknown>) => {
const { data } = await naturali.agents.createAgent({
path: { project_id: process.env.PROJECT! },
body: { ai_provider_id: process.env.PROVIDER!, ...body },
});
return data!.id;
};
const triage = await create({
name: 'support-triage',
instructions:
'You receive a customer message. Classify it: refund if the customer asks for their money back, other for anything else.',
output_schema: {
type: 'object',
properties: { category: { type: 'string', enum: ['refund', 'other'] } },
required: ['category'],
},
});
const refunds = await create({
name: 'support-refunds',
instructions:
'You answer refund requests for a bakery. Refunds: custom cakes within 48 hours of pickup with the receipt; bread and pastries are exchange only, same day. Reply in at most two sentences.',
});
const general = await create({
name: 'support-general',
instructions:
'You answer general questions for a bakery open 7am to 6pm, Monday to Saturday. Reply in at most two sentences.',
});
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-triage\",
\"instructions\": \"You receive a customer message. Classify it: refund if the customer asks for their money back, other for anything else.\",
\"output_schema\": {
\"type\": \"object\",
\"properties\": { \"category\": { \"type\": \"string\", \"enum\": [\"refund\", \"other\"] } },
\"required\": [\"category\"]
}
}"
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-refunds\",
\"instructions\": \"You answer refund requests for a bakery. Refunds: custom cakes within 48 hours of pickup with the receipt; bread and pastries are exchange only, same day. Reply in at most two sentences.\"
}"
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-general\",
\"instructions\": \"You answer general questions for a bakery open 7am to 6pm, Monday to Saturday. Reply in at most two sentences.\"
}"
Each call answers with the agent; the triage agent carries its schema:
{
"id": "agent_i4N8yGO6JtCTL29R",
"project_id": "proj_cT9LACJi0WypPf5U",
"ai_provider_id": "aip_CLcDNRe8GpP1pBNs",
"name": "support-triage",
"output_schema": {
"type": "object",
"required": ["category"],
"properties": {
"category": { "enum": ["refund", "other"], "type": "string" }
}
},
"version": 1
}
export TRIAGE=agent_i4N8yGO6JtCTL29R
export REFUNDS=agent_0aaNr99iJ4yHxpAk
export GENERAL=agent_lYHV0xJwhYKrzemY
2. Describe the branching graph and validate it
Four nodes:
triagewrites the classification tocategory. With a schema, the agent's parsed answer isoutput.object, sooutput.object.categoryis the field.routeis aconditionnode. Itsexpressionis JSON Logic over the run's state, and whatever it evaluates to is the label it emits — hererefundwhencategoryisrefund,otherfor anything else.refund_replyandgeneral_replyboth writereply, so a finished run has a reply whichever branch ran.
An edge with a condition is followed only when it matches the label route
emitted. The edge into route has none, so it is always followed.
NODES=$(cat <<EOF
[
{
"id": "triage",
"type": "agent",
"agent_id": "$TRIAGE",
"input_mapping": { "message": { "var": "input.message" } },
"state_mapping": { "category": { "var": "output.object.category" } }
},
{
"id": "route",
"type": "condition",
"expression": {
"if": [{ "==": [{ "var": "category" }, "refund"] }, "refund", "other"]
}
},
{
"id": "refund_reply",
"type": "agent",
"agent_id": "$REFUNDS",
"input_mapping": { "message": { "var": "input.message" } },
"state_mapping": { "reply": { "var": "output.content" } }
},
{
"id": "general_reply",
"type": "agent",
"agent_id": "$GENERAL",
"input_mapping": { "message": { "var": "input.message" } },
"state_mapping": { "reply": { "var": "output.content" } }
}
]
EOF
)
EDGES='[
{ "from": "triage", "to": "route" },
{ "from": "route", "to": "refund_reply", "condition": "refund" },
{ "from": "route", "to": "general_reply", "condition": "other" }
]'
The if falls back to other, so a value you did not plan for still lands on
a branch rather than on none.
POST /v1/projects/{project_id}/orchestrations/validate
checks the graph without saving it.
- CLI
- SDK
- curl
naturali validate-orchestration \
--project-id "$PROJECT" \
--nodes "$NODES" \
--edges "$EDGES"
const replyNode = (id: string, agent_id: string) => {
return {
id,
type: 'agent' as const,
agent_id,
input_mapping: { message: { var: 'input.message' } },
state_mapping: { reply: { var: 'output.content' } },
};
};
const graph = {
nodes: [
{
id: 'triage',
type: 'agent' as const,
agent_id: triage,
input_mapping: { message: { var: 'input.message' } },
state_mapping: { category: { var: 'output.object.category' } },
},
{
id: 'route',
type: 'condition' as const,
expression: {
if: [{ '==': [{ var: 'category' }, 'refund'] }, 'refund', 'other'],
},
},
replyNode('refund_reply', refunds),
replyNode('general_reply', general),
],
edges: [
{ from: 'triage', to: 'route' },
{ from: 'route', to: 'refund_reply', condition: 'refund' },
{ from: 'route', to: 'general_reply', condition: 'other' },
],
};
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
output_mapping puts both the decision and the answer in a finished run's
output, so a caller sees which way the message went without reading the
steps.
- CLI
- SDK
- curl
naturali create-orchestration \
--project-id "$PROJECT" \
--name support-triage \
--nodes "$NODES" \
--edges "$EDGES" \
--output-mapping '{
"category": { "var": "state.category" },
"reply": { "var": "state.reply" }
}'
const { data: orchestration } =
await naturali.orchestrations.createOrchestration({
path: { project_id: process.env.PROJECT! },
body: {
name: 'support-triage',
...graph,
output_mapping: {
category: { var: 'state.category' },
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-triage\",
\"nodes\": $NODES,
\"edges\": $EDGES,
\"output_mapping\": {
\"category\": { \"var\": \"state.category\" },
\"reply\": { \"var\": \"state.reply\" }
}
}"
{
"id": "orch_2Aaun8XhlWvrHtGH",
"project_id": "proj_cT9LACJi0WypPf5U",
"name": "support-triage",
"version": 1,
"edges": [
{ "from": "triage", "to": "route" },
{ "from": "route", "to": "refund_reply", "condition": "refund" },
{ "from": "route", "to": "general_reply", "condition": "other" }
],
"output_mapping": {
"reply": { "var": "state.reply" },
"category": { "var": "state.category" }
}
}
(nodes omitted — they come back as sent.)
export ORCHESTRATION=orch_2Aaun8XhlWvrHtGH
4. Run a refund request
"wait": true holds the request open until the run settles, so the answer is
the finished run. These runs settled in about two seconds.
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION" \
--input '{ "message": "The cake I picked up yesterday was dry. Can I get my money back?" }' \
--wait true
const { data: refundRun } = await naturali.orchestrations.startOrchestrationRun(
{
path: { project_id: process.env.PROJECT! },
body: {
orchestration_id: orchestration!.id,
input: {
message:
'The cake I picked up yesterday was dry. Can I get my money back?',
},
wait: true,
},
}
);
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\": { \"message\": \"The cake I picked up yesterday was dry. Can I get my money back?\" },
\"wait\": true
}"
{
"id": "orch_run_mKndShNtDd6ebowa",
"orchestration_id": "orch_2Aaun8XhlWvrHtGH",
"orchestration_version": 1,
"status": "succeeded",
"output": {
"category": "refund",
"reply": "Please provide a receipt. Refunds are only offered for custom cakes returned within 48 hours of pickup."
},
"node_executions": [
{
"node_id": "triage",
"node_type": "agent",
"status": "completed",
"output": {
"object": { "category": "refund" },
"content": "{\"category\":\"refund\"}"
}
},
{
"node_id": "route",
"node_type": "condition",
"status": "completed",
"output": { "label": "refund" }
},
{
"node_id": "refund_reply",
"node_type": "agent",
"status": "completed",
"output": {
"object": null,
"content": "Please provide a receipt. Refunds are only offered for custom cakes returned within 48 hours of pickup."
}
},
{
"node_id": "general_reply",
"node_type": "agent",
"status": "skipped",
"input": null,
"output": null,
"started_at": null
}
],
"trace_id": "trace_N3zNHR0UhbNoj1U9",
"completed_at": "2026-10-03T10:27:43.958Z"
}
(node_executions trimmed; each also carries its input and timings.)
route emitted refund, so only refund_reply ran. general_reply is still
in the record, as skipped with no input, output or start time: it was never
dispatched.
The answer to a waited start carries no
usage. Read the run back with
GET /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id}
for the cost of the steps that ran.
5. Run a general question
The same orchestration, a message that is not a refund request:
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION" \
--input '{ "message": "Are you open on Sunday?" }' \
--wait true
const { data: generalRun } =
await naturali.orchestrations.startOrchestrationRun({
path: { project_id: process.env.PROJECT! },
body: {
orchestration_id: orchestration!.id,
input: { message: 'Are you open on Sunday?' },
wait: true,
},
});
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\": { \"message\": \"Are you open on Sunday?\" },
\"wait\": true
}"
{
"id": "orch_run_p0CMMm2cKBvBMRx0",
"status": "succeeded",
"output": {
"category": "other",
"reply": "No, sorry, we are closed on Sundays.\n\nWe open daily from 7:00 AM to 6:00 PM, Monday through Saturday."
},
"node_executions": [
{
"node_id": "triage",
"node_type": "agent",
"status": "completed",
"output": {
"object": { "category": "other" },
"content": "{\"category\":\"other\"}"
}
},
{
"node_id": "route",
"node_type": "condition",
"status": "completed",
"output": { "label": "other" }
},
{
"node_id": "general_reply",
"node_type": "agent",
"status": "completed",
"output": {
"object": null,
"content": "No, sorry, we are closed on Sundays.\n\nWe open daily from 7:00 AM to 6:00 PM, Monday through Saturday."
}
},
{
"node_id": "refund_reply",
"node_type": "agent",
"status": "skipped",
"input": null,
"output": null,
"started_at": null
}
],
"trace_id": "trace_g9dgrKhv0YXyNWWD"
}
That is the proof, read across the two runs:
- One orchestration, one version, two paths:
routeemittedrefundthe first time andotherthe second. - In each run, the matching reply node is
completedand the other isskipped— the branch not taken never generated. output.categoryandoutput.replysay which way each message went and what it was told, without reading the steps.
What's next
- Loop, wait and poll —
loop,delayandpollnodes repeat a step over a list, pause a run, or retry a tool until a condition holds; see the node types. - Pause for a person — put an
approvalnode on one branch so only refunds wait for a human; Pause a run for a human decision builds one. - Merge branches back — edges into a shared node can wait for
alloranyof their sources withactivation_groupandactivation_condition. - Run it on a schedule — a trigger can target an orchestration the way Run an agent on a schedule targets an agent.