Skip to main content

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:

  1. Create the three agents.
  2. Describe the branching graph and validate it.
  3. Create the orchestration.
  4. Run a refund request.
  5. 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 as PROJECT and a working AI provider id as PROVIDER — from Your first agent generation.
  • How nodes, edges, input_mapping and state_mapping fit 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
Only the branch taken is a run

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.

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.'

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:

  • triage writes the classification to category. With a schema, the agent's parsed answer is output.object, so output.object.category is the field.
  • route is a condition node. Its expression is JSON Logic over the run's state, and whatever it evaluates to is the label it emits — here refund when category is refund, other for anything else.
  • refund_reply and general_reply both write reply, 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.

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

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

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

naturali start-orchestration-run \
--project-id "$PROJECT" \
--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: route emitted refund the first time and other the second.
  • In each run, the matching reply node is completed and the other is skipped — the branch not taken never generated.
  • output.category and output.reply say which way each message went and what it was told, without reading the steps.

What's next​

  • Loop, wait and poll — loop, delay and poll nodes 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 approval node 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 all or any of their sources with activation_group and activation_condition.
  • Run it on a schedule — a trigger can target an orchestration the way Run an agent on a schedule targets an agent.