Skip to main content

Orchestration Control Flow: Delay, Poll, and Loop

This tutorial focuses on the control-flow nodes of the Orchestrations module — the ones that pace, wait, repeat, and branch a run rather than call an LLM. You will build a sub-orchestration, a builtin tool for the poll node, and a main orchestration wiring delay → poll → loop → condition → transform, then run it and inspect the per-node executions.

Everything here is deterministic — no AI provider is required. For agent nodes (LLM calls), see Orchestrate a Sonnet; a reference table at the end maps every node type to where it is demonstrated.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • New to SOAT? Read Key Concepts to understand projects, tools, and runs first.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • For production hardening (secrets, env vars), see Configuration.
  • Server is at http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role. See Users for authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project

Every resource lives inside a project.

PROJECT_ID=$(soat create-project --name "Node Tour" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create the per-item sub-orchestration

The loop node runs a whole orchestration once per item in a collection. Create a tiny child orchestration that receives one item and echoes it through a transform node. The loop injects each element under the item variable, seeded into the sub-run's input namespace, so the child reads it with {"var": "input.item"}.

SUB_ORCH_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Process One Item" \
--nodes '[{"id":"echo","type":"transform","expression":{"var":"input.item"},"state_mapping":{"state.processed":{"var":"output.result"}}}]' \
--edges '[]' | jq -r '.id')
echo "SUB_ORCH_ID: $SUB_ORCH_ID"

Step 4 — Create the tool the poll node calls

A poll node repeatedly calls a Tool until a JSON Logic exit condition on its response holds. Create a builtin tool that reads this project back via the get-project action. Polling a resource until a field reaches an expected value is the canonical use of a poll node — here the project is already readable, so the loop exits on the first attempt.

CHECK_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "read-project" \
--type "builtin" \
--description "Read this project so the poll node can check readiness" \
--actions '["get-project"]' \
--preset-parameters '{"project_id": "'"$PROJECT_ID"'"}' | jq -r '.id')
echo "CHECK_TOOL_ID: $CHECK_TOOL_ID"

Step 5 — Create the control-flow orchestration

This orchestration chains the control-flow nodes:

  • delay — waits a fixed duration. Accepts the friendly suffix form (1s, 5m, 2h) or ISO 8601 (PT1S).
  • poll — calls the tool each attempt and stops when exit_condition is truthy, bounded by interval + max_iterations. The condition is evaluated against the run state plus response (the latest tool result) and attempt. See Polling.
  • loop — runs orchestration_id once per element of collection, injecting each as item_variable. This is the same orchestration_id field the standalone sub_orchestration node uses. See Loops.
  • condition — emits a label; outgoing edges select a branch with condition: "<label>".
  • transform — the terminal node on each branch.
ORCH_NODES='[
{"id":"pace","type":"delay","duration":"1s"},
{"id":"wait-ready","type":"poll","tool_id":"'"$CHECK_TOOL_ID"'","operation_id":"get-project","exit_condition":{"==":[{"var":"response.id"},"'"$PROJECT_ID"'"]},"interval":"1s","max_iterations":5,"state_mapping":{"state.project":{"var":"output.result"}}},
{"id":"process-each","type":"loop","orchestration_id":"'"$SUB_ORCH_ID"'","collection":"state.input.items","item_variable":"item","state_mapping":{"state.results":{"var":"output.results"}}},
{"id":"route","type":"condition","expression":{"if":[{"var":"results"},"processed","none"]}},
{"id":"summary-processed","type":"transform","expression":"Items were processed.","state_mapping":{"state.summary":{"var":"output.result"}}},
{"id":"summary-none","type":"transform","expression":"No items to process.","state_mapping":{"state.summary":{"var":"output.result"}}}
]'

ORCH_EDGES='[
{"from":"pace","to":"wait-ready"},
{"from":"wait-ready","to":"process-each"},
{"from":"process-each","to":"route"},
{"from":"route","to":"summary-processed","condition":"processed"},
{"from":"route","to":"summary-none","condition":"none"}
]'

ORCHESTRATION_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Control Flow Tour" \
--description "delay, poll, loop, condition" \
--nodes "$ORCH_NODES" \
--edges "$ORCH_EDGES" | jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"

Step 6 — Run it

Start a run with a list of items. The loop processes each through the sub-orchestration, and the condition routes to summary-processed because the results are non-empty.

RUN=$(soat start-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--input '{"items":["alpha","beta","gamma"]}')

printf '%s\n' "$RUN" | jq '{status, output}'
RUN_ID=$(printf '%s\n' "$RUN" | jq -r '.id')
echo "RUN_ID: $RUN_ID"

Expected output:

{
"status": "succeeded",
"output": {
"summary-processed": {
"result": "Items were processed."
}
}
}

Step 7 — Inspect the per-node executions

get-orchestration-run returns the run state and one record per executed node. Note that summary-none shows skipped — the condition routed away from it.

soat get-orchestration-run \
--orchestration-run-id "$RUN_ID" | jq '{status, state: {results: .state.results, summary: .state.summary}, nodes: [.node_executions[] | {node_id, node_type, status}]}'

Look for:

  • state.results — one entry per item, each the sub-orchestration's output.
  • state.summary"Items were processed." (written by the branch the condition selected).
  • node_executionswait-ready completed after one attempt; summary-none is skipped.

How It Works

Each node type's full semantics are documented in Orchestrations — Node Types. Two details worth calling out here: poll exhausts max_iterations by completing with condition_met: false unless fail_on_timeout: true, and condition records the unselected branch as skipped.

To see the none branch, run again with --input '{"items":[]}': the empty collection makes loop produce [], the condition evaluates to none, and summary-none runs instead.

Every node type

This tutorial exercises the control-flow nodes. The full set of node types:

NodeWhere to see it
delayThis tutorial — Step 5
pollThis tutorial — Step 5 (Polling)
loopThis tutorial — Step 5 (Loops)
conditionThis tutorial; Conditional Branching
transformThis tutorial; Orchestrate a Sonnet
toolThis tutorial (the poll's builtin tool); Orchestrate a Sonnet
sub_orchestrationSame orchestration_id field the loop uses here — runs a child orchestration as a single step
agentOrchestrate a Sonnet, Multi-Agent Orchestration
knowledgeKnowledge — searches a knowledge source into state
memory_writeMemories — writes a memory entry
humanOrchestrations — Node Types — pauses the run for external input
webhookOrchestrations — Node Types — emits or awaits an HTTP callback