Skip to main content
The conditional node is the control-flow node. It decides where a run goes next — and, in loop mode, how many times a section of the graph repeats. It is the only node whose job is routing rather than producing data, so its output exists to drive edges, not to be consumed by later steps. A conditional node has exactly one condition_type, set when you add it:
  • expression — route on a comparison you build visually (branches with operators) or on a Python expression.
  • llm — route on a language-model decision, useful when the rule is fuzzy (“is this message a complaint?”).
  • loop — turn the node into a loop controller that repeats a body of nodes in for, foreach, or while mode.
For the engine that compiles these decisions into an executable state graph and runs them, see Workflow engine & nodes. For the {{node_id.field}} reference system used throughout this page, see Variables & references.

What the node does

When a run reaches a conditional node, the engine runs the node function for its condition_type, then the edge leaving the node reads the result and picks the next node. The decision (the “which way”) and the routing (the “go there”) are two separate steps:
  1. The conditional node function evaluates the condition and writes a small marker into run state.
  2. A conditional edge attached to the node reads that marker and returns the id of the next node.
This split matters when you read run output: an expression conditional writes {"matched_target": "<node_id>"} under its own id, and an llm conditional writes llm_routing_decision at the top level of state. Neither produces a “result” you would normally reference with {{...}}. The conditional node is retry-wrapped like most node types — a transient failure in an llm condition can retry per the node’s retry_config. It emits the standard node_started, node_retry (on retry), and node_error (on final failure) events over the run stream; see SSE run streaming.

ConditionalNodeConfig reference

Every conditional node carries a conditional_config object. The fields you set depend on condition_type; the validator rejects a config that is missing the fields its type requires.
string
required
One of expression, llm, or loop. Required. Determines which other fields are read and how the node routes.
ExpressionBranch[]
Visual branches for expression routing. Evaluated in order; the first branch whose comparison is true wins. See Expression branches. Used only when condition_type is expression.
string
A Python expression evaluated in a sandbox, used for expression routing when you are not using visual branches. The state dict and the builtins int, str, float, bool are in scope. Its result is matched against routes (see Python expression mode). Used only when condition_type is expression.
object
A map of condition value to target node id, in the shape {value: node_id}. Required for condition_type: llm (the LLM’s text answer is matched against the keys by the routing edge). Optional for expression mode when paired with expression. Not used by expression_branches.
string
The node id to route to when no branch matches. Read by the expression-branches routing edge. If unset and no branch matches, the edge falls back to ending the run. Strongly recommended whenever you use expression_branches.
LLMConfig
The model configuration for condition_type: llm. Required for that type. Same LLMConfig shape used by the LLM node: integration_name, provider_id, model_id, optional temperature (default 0.4), and optional credential_id. Managed (modulexai) and BYOK providers are both supported — see LLM providers.
string
The prompt sent to the model for condition_type: llm. Supports {{node_id.field}} references, which are resolved against run state before the call. If omitted, the engine sends a string rendering of the whole state as the prompt — set this in practice.
LoopConfig
The loop definition for condition_type: loop. Required for that type. See Loops for every field.
The output_key field that appears on many other node configs does not apply here — a conditional node writes a fixed routing marker, not a named result. Do not rely on output_key for conditional nodes.

Validation

The config validator enforces these rules when the workflow is saved or run: These surface as a 422 validation error on the workflow save/update routes (the {detail} HTTPException shape — see Errors & status codes), not as a run-time failure.

Expression branches (visual routing)

Visual branches are the default way to build a conditional. Each branch is an ExpressionBranch: a source value, an operator, a value to compare against, and the node to route to if the comparison is true.
string
required
A unique branch identifier (used for logging and the canvas).
string
required
The value to test, written as a {{...}} reference — for example {{input.priority}} or {{node_classify.label}}. Resolved against run state before the comparison.
string
required
The comparison. One of: equals, not_equals, greater_than, less_than, greater_than_or_equals, less_than_or_equals, contains, not_contains, starts_with, ends_with, is_empty, is_not_empty.
string
The value to compare against. Not required for is_empty / is_not_empty. A value that itself contains {{...}} is resolved against state first, so you can compare two run values.
string
required
The node id to route to when this branch’s comparison is true.

How branches are evaluated

Branches are checked top to bottom, and the first match wins:
  1. The source reference is resolved to its run value. A value containing {{...}} is resolved too.
  2. Type coercion runs before comparison. If both sides look like integers, both are parsed as int; if both look like floats, both as float; otherwise both stay strings. This makes greater_than and friends behave numerically when the data is numeric, and lexically when it is not.
  3. The operator is applied. equals and not_equals are lenient — they also compare the string forms, so 5 and "5" are treated as equal. contains, starts_with, and ends_with always operate on string forms. is_empty is true for None, "", and [].
  4. On the first true branch, the engine stores {matched_target: <branch.target>} and stops checking.
  5. If no branch matched and default_target is set, matched_target becomes the default.
The marker is written to state under the node’s own id:
Order is significant. Put the most specific branches first. A broad branch such as {{input.text}} contains an empty string would match everything and shadow later branches.

Operator reference

Python expression mode

When a comparison is more than the operators allow, set expression instead of branches. The expression is evaluated in a restricted sandbox: only the run state dict and the builtins int, str, float, bool are available — there is no file, network, or import access. The expression’s result is matched against routes. A route key matches when str(result) equals the key, or when result equals the route’s target value. For example, with:
an expression result of "high" routes to node_escalate. If the expression raises, the engine logs the error and writes no matched_target, so the routing edge falls back to default_target (or ends the run).
The sandbox blocks builtins (eval(..., {"__builtins__": {}}, namespace)). It removes obvious escape hatches but is still eval. Keep expressions simple and never build one from untrusted input. Prefer visual branches when they are expressive enough.

LLM conditions

An llm condition asks a model to choose the next step. Set condition_type: llm, provide an llm config, a prompt_template, and a routes map whose keys are the answers you expect the model to return. At run time the node:
  1. Resolves {{...}} references in prompt_template against state.
  2. Calls the model once with that prompt.
  3. Stores the model’s trimmed text answer in state as llm_routing_decision.
  4. The routing edge maps that answer to a target node via routes.
Write the prompt so the model returns exactly one of your route keys, and add a catch-all route (or default_target) for answers that do not match. Keep temperature low for deterministic routing.
The LLM’s answer is matched against routes by the edge. If the model returns text that is not a route key, the run has no matching target and the edge falls back to ending the run. Constrain the prompt tightly and provide a fallback route.

Loops

In loop mode the conditional node becomes a loop controller: it owns the loop’s state, decides whether to run the body again or exit, and (optionally) accumulates each iteration’s result. Set condition_type: loop and a loop_config. A loop has three modes:
  • for — repeat a fixed number of times.
  • foreach — iterate over a collection, one item per iteration.
  • while — repeat as long as a Python condition is true.

LoopConfig reference

string
required
A unique id for the loop. It names the auto-created loop state fields ({{loop_id}}_iteration, {{loop_id}}_results, and others — see below).
string
required
One of for, foreach, or while.
integer
FOR mode: the number of iterations. Provide this or iterations_ref.
string
FOR mode: a {{...}} reference resolved to the iteration count at run time, for example {{input.count}}. Provide this or iterations.
string
required
FOREACH mode: a {{...}} reference to the array to iterate, for example {{node_fetch.items}}. A non-list value is wrapped as a single-item list; None or an empty list completes the loop immediately with no body runs.
boolean
default:"false"
FOREACH mode: run all items in parallel by fanning out instead of one at a time. See the note below — parallel mode is only honored on edge-based loops.
string
WHILE mode: a Python expression evaluated each iteration in the sandbox (state, int, str, float, bool, len in scope). The loop continues while it is true, for example state.get("node_score", {}).get("value", 0) < 80.
integer
default:"100"
Safety cap. The loop force-exits once iteration reaches this, regardless of mode. Protects against runaway WHILE conditions.
string
required
The first node of the loop body. Each iteration enters here.
string
The last node of the loop body, which cycles back to the controller. Defaults to body_target (a single-node body).
string
default:"__end__"
Where to go when the loop finishes. Defaults to __end__ (terminate the run). An empty value is normalized to __end__.
boolean
default:"true"
Collect each iteration’s output into {{loop_id}}_results. When true, the controller appends a value every iteration.
string
A {{...}} reference to the specific value to accumulate each iteration, for example {{loop_body.result}}. If unset, the whole body_target node output is accumulated.

Loop state fields

The engine adds these fields to run state automatically, named by loop_id. Read them from inside the loop body with {{...}} references: For example, a FOREACH body node references the current item with {{my_loop_item}} when loop_id is my_loop.

How a loop is wired

The controller and routing are wired by the engine:
  1. The previous node connects to the controller.
  2. The controller initializes loop state on the first call, then increments the iteration and accumulates results on each subsequent call.
  3. A routing edge on the controller decides body_target (continue) or exit_target (done), based on the mode’s condition and max_iterations.
  4. body_end (or body_target) cycles back to the controller to close the loop.
Each node transition counts toward the workflow’s recursion_limit (default 500, which supports roughly 100 iterations). A loop that needs more iterations needs a higher recursion_limit in the workflow config. See Workflow engine & nodes.
Parallel FOREACH applies only to edge-based loops. When you define a loop with parallel: true on a conditional node, the node is wired as a sequential controller — the parallel flag is not honored on this path. To fan out a collection across parallel executions with the Send API, define the loop as an edge condition of type: loop instead of as a conditional node. Both paths share the same LoopConfig.

Edge routing

A conditional node never routes by itself — a conditional edge attached to it does. How the edge reads the decision depends on the type:
The engine adds a conditional edge whose path map is the set of all branch targets plus default_target. At run time the edge reads matched_target from the node’s state entry and returns it. If there is no matched_target, it returns default_target, or ends the run if that is unset.
Edges can also carry their own condition independent of a conditional node. An EdgeDefinition may set condition.type to expression (with branches and a default), llm (with prompt_template and a route_map), or loop (with a loop_config). This is the lower-level form the builder generates; most authors work through conditional nodes.
An edge condition.type of function (a named routing function) is defined in the schema but is not implemented by the engine — it raises Unsupported condition type. Do not use it. See Known limitations.

Inputs and outputs

Inputs. A conditional node reads from run state through {{node_id.field}} references in source values, value comparands, the expression, the LLM prompt_template, and the loop’s collection / iterations_ref / condition. References resolve to native typed values; an unresolved reference is left intact in a templated string and resolves to None as a standalone value. See Variables & references. Outputs. What lands in state depends on the type:
object
Writes {matched_target: "<node_id>"} under the node’s own id, or nothing if no branch matched and no default_target is set.
object
Writes {llm_routing_decision: "<text>"} at the top level of state — not under the node id.
object
Writes the loop state fields above ({{loop_id}}_iteration, {{loop_id}}_results, and the FOREACH item fields). The body node’s own output is stored under the body node’s id as usual.
These outputs exist to drive routing. You normally read loop fields ({{loop_id}}_item, {{loop_id}}_results) from a workflow, but you rarely reference an expression conditional’s matched_target directly.

Credit impact

expression and loop conditions perform no model calls and consume no credits — they are pure evaluation against run state. An llm condition makes one model call per evaluation and is metered like any other LLM call. With a managed (modulexai) model the call records credit usage by input/output tokens; with a BYOK provider the call is uncosted by ModuleX. In a loop whose body or controller triggers an LLM condition, that cost is incurred per iteration. Usage recording is best-effort and never fails the run. See Credits & metering. Because LLM conditions run on the workflow surface, a credit or rate-limit denial mid-run surfaces as a node_error event whose reason carries the error code (for example credit_exhausted) rather than crashing the run. The flat DenialEnvelope ({code, layer, key, current, limit, reason}) returned as 402 / 403 / 429 applies when you start a run on the run surface; see Usage gating & limits and Errors & status codes.

Errors

A failure-to-route ends the run quietly rather than erroring. Always set a default_target (branches/expression) or a catch-all routes key (LLM) so a run never dead-ends unexpectedly.

Worked example

A support-triage workflow: classify an incoming message with an expression branch on a prior classifier’s label, then route to the matching queue. The classifier (node_classify) is an LLM node that writes a label field; the conditional routes on it.
For that input the classifier writes {"label": "billing"}, the conditional’s first branch matches, and the run routes to node_billing_queue. As seen on the run stream, the conditional node’s state entry is:
A message the classifier labels account (not one of the branches) falls through to default_target and routes to node_general_queue. To stream and observe the routing live, see SSE run streaming; to run a workflow end to end, see Run a workflow.

A FOREACH loop variant

To process every item in a list, replace the conditional with a loop controller. Here a loop iterates an array produced by an earlier node and runs a single body node per item, accumulating each result:
Conditional node (loop mode)
The body node node_process reads the current item with {{items_item}} and its index with {{items_index}}. After the last item, the loop routes to node_summarize, which can read every collected result from {{items_results}}.

Node types reference

All nine node types and the state convention they share.

Workflow engine & nodes

How edges, loops, and state are compiled and run.

Variables & references

The {{node_id.field}} reference system used by every condition.

Guardrails node

Validate content and route on the result, complementing conditional routing.