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 one orchestration that uses, in order, a delay, a poll, a loop (which runs a sub-orchestration per item), and a condition that routes to a terminal transform.
You will:
- Create a project.
- Create a small sub-orchestration that the loop runs once per item.
- Create a SOAT tool the
pollnode calls each attempt. - Define the main orchestration wiring
delay → poll → loop → condition → transform. - 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 Advanced Configuration.
- Server is at
http://localhost:5047.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
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.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Every resource lives inside a project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Node Tour" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Node Tour' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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, so the child reads it with {"var": "item"}.
- CLI
- SDK
- curl
SUB_ORCH_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Process One Item" \
--nodes '[{"id":"echo","type":"transform","expression":{"var":"item"},"output_mapping":{"result":"state.processed"}}]' \
--edges '[]' | jq -r '.id')
echo "SUB_ORCH_ID: $SUB_ORCH_ID"
const { data: subOrch } = await adminSoat.orchestrations.createOrchestration({
body: {
project_id: PROJECT_ID,
name: 'Process One Item',
nodes: [
{
id: 'echo',
type: 'transform',
expression: { var: 'item' },
output_mapping: { result: 'state.processed' },
},
],
edges: [],
},
});
const SUB_ORCH_ID = subOrch.id;
SUB_ORCH_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Process One Item\",\"nodes\":[{\"id\":\"echo\",\"type\":\"transform\",\"expression\":{\"var\":\"item\"},\"output_mapping\":{\"result\":\"state.processed\"}}],\"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 SOAT 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.
- CLI
- SDK
- curl
CHECK_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "read-project" \
--type "soat" \
--description "Read this project so the poll node can check readiness" \
--actions '["get-project"]' \
--preset-parameters '{"projectId": "'"$PROJECT_ID"'"}' | jq -r '.id')
echo "CHECK_TOOL_ID: $CHECK_TOOL_ID"
const { data: checkTool } = await adminSoat.tools.createTool({
body: {
project_id: PROJECT_ID,
name: 'read-project',
type: 'soat',
description: 'Read this project so the poll node can check readiness',
actions: ['get-project'],
preset_parameters: { projectId: PROJECT_ID },
},
});
const CHECK_TOOL_ID = checkTool.id;
CHECK_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"read-project\",\"type\":\"soat\",\"description\":\"Read this project so the poll node can check readiness\",\"actions\":[\"get-project\"],\"preset_parameters\":{\"projectId\":\"$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 fixedduration. Accepts the friendly suffix form (1s,5m,2h) or ISO 8601 (PT1S).poll— calls the tool each attempt and stops whenexit_conditionis truthy, bounded byinterval+max_iterations. The condition is evaluated against the run state plusresponse(the latest tool result) andattempt. See Polling.loop— runsorchestration_idonce per element ofcollection, injecting each asitem_variable. This is the sameorchestration_idfield the standalonesub_orchestrationnode uses. See Loops.condition— emits a label; outgoing edges select a branch withcondition: "<label>".transform— the terminal node on each branch.
- CLI
- SDK
- curl
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,"output_mapping":{"result":"state.project"}},
{"id":"process-each","type":"loop","orchestration_id":"'"$SUB_ORCH_ID"'","collection":"state.items","item_variable":"item","output_mapping":{"results":"state.results"}},
{"id":"route","type":"condition","expression":{"if":[{"var":"results"},"processed","none"]}},
{"id":"summary-processed","type":"transform","expression":"Items were processed.","output_mapping":{"result":"state.summary"}},
{"id":"summary-none","type":"transform","expression":"No items to process.","output_mapping":{"result":"state.summary"}}
]'
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"
const { data: orchestration } =
await adminSoat.orchestrations.createOrchestration({
body: {
project_id: PROJECT_ID,
name: 'Control Flow Tour',
description: 'delay, poll, loop, condition',
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,
output_mapping: { result: 'state.project' },
},
{
id: 'process-each',
type: 'loop',
orchestration_id: SUB_ORCH_ID,
collection: 'state.items',
item_variable: 'item',
output_mapping: { results: 'state.results' },
},
{
id: 'route',
type: 'condition',
expression: { if: [{ var: 'results' }, 'processed', 'none'] },
},
{
id: 'summary-processed',
type: 'transform',
expression: 'Items were processed.',
output_mapping: { result: 'state.summary' },
},
{
id: 'summary-none',
type: 'transform',
expression: 'No items to process.',
output_mapping: { result: 'state.summary' },
},
],
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' },
],
},
});
const ORCHESTRATION_ID = orchestration.id;
ORCHESTRATION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Control Flow Tour\",\"description\":\"delay, poll, loop, condition\",\"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,\"output_mapping\":{\"result\":\"state.project\"}},{\"id\":\"process-each\",\"type\":\"loop\",\"orchestration_id\":\"$SUB_ORCH_ID\",\"collection\":\"state.items\",\"item_variable\":\"item\",\"output_mapping\":{\"results\":\"state.results\"}},{\"id\":\"route\",\"type\":\"condition\",\"expression\":{\"if\":[{\"var\":\"results\"},\"processed\",\"none\"]}},{\"id\":\"summary-processed\",\"type\":\"transform\",\"expression\":\"Items were processed.\",\"output_mapping\":{\"result\":\"state.summary\"}},{\"id\":\"summary-none\",\"type\":\"transform\",\"expression\":\"No items to process.\",\"output_mapping\":{\"result\":\"state.summary\"}}],\"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\"}]}" \
| 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.
- CLI
- SDK
- curl
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."
}
}
}
const { data: run } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { items: ['alpha', 'beta', 'gamma'] },
},
});
console.log('Status:', run.status);
console.log('Output:', run.output);
const RUN_ID = run.id;
RUN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"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"
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.
- CLI
- SDK
- curl
soat get-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--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 theconditionselected).node_executions—wait-readycompleted after one attempt;summary-noneisskipped.
const { data: runState } = await adminSoat.orchestrations.getOrchestrationRun({
path: { run_id: RUN_ID },
});
console.log('results:', runState.state.results);
console.log('summary:', runState.state.summary);
console.log(
'nodes:',
runState.node_executions?.map((n) => ({
node_id: n.node_id,
node_type: n.node_type,
status: n.status,
}))
);
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, results: .state.results, summary: .state.summary, nodes: [.node_executions[] | {node_id, node_type, status}]}'
How It Works
delayholds the run for itsdurationbefore activating the next node. It runs inside the synchronous run loop, so keep durations short and bounded.pollcalls its tool, evaluatesexit_conditionagainst{ ...state, response, attempt }, and either stops (truthy) or waitsintervaland retries — up tomax_iterations. On exhaustion it completes withcondition_met: falseunlessfail_on_timeout: true. Each polled call should be safe to repeat.loopfans out overcollection, runningorchestration_idonce per item with the element bound toitem_variable, and collects each sub-run's output into{ results: [...] }.conditionturns a JSON Logic result into a string label; edges pick the matching branch withcondition. Unselected branches are recorded asskipped.transformcomputes a value (or, as here, returns a literal) and writes it to state viaoutput_mapping.
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:
| Node | Where to see it |
|---|---|
delay | This tutorial — Step 5 |
poll | This tutorial — Step 5 (Polling) |
loop | This tutorial — Step 5 (Loops) |
condition | This tutorial; Conditional Branching |
transform | This tutorial; Orchestrate a Sonnet |
tool | This tutorial (the poll's SOAT tool); Orchestrate a Sonnet |
sub_orchestration | Same orchestration_id field the loop uses here — runs a child orchestration as a single step |
agent | Orchestrate a Sonnet, Multi-Agent Orchestration |
knowledge | Knowledge — searches a knowledge source into state |
memory_write | Memories — writes a memory entry |
human | Orchestrations — Node Types — pauses the run for external input |
webhook | Orchestrations — Node Types — emits or awaits an HTTP callback |