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

  1. Create a project.
  2. Create a small sub-orchestration that the loop runs once per item.
  3. Create a SOAT tool the poll node calls each attempt.
  4. Define the main orchestration wiring delay → poll → loop → condition → transform.
  5. 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.
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, so the child reads it with {"var": "item"}.

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"

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.

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"

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,"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"

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-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 the condition selected).
  • node_executionswait-ready completed after one attempt; summary-none is skipped.

How It Works

  • delay holds the run for its duration before activating the next node. It runs inside the synchronous run loop, so keep durations short and bounded.
  • poll calls its tool, evaluates exit_condition against { ...state, response, attempt }, and either stops (truthy) or waits interval and retries — up to max_iterations. On exhaustion it completes with condition_met: false unless fail_on_timeout: true. Each polled call should be safe to repeat.
  • loop fans out over collection, running orchestration_id once per item with the element bound to item_variable, and collects each sub-run's output into { results: [...] }.
  • condition turns a JSON Logic result into a string label; edges pick the matching branch with condition. Unselected branches are recorded as skipped.
  • transform computes a value (or, as here, returns a literal) and writes it to state via output_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:

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 SOAT 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