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 infor,foreach, orwhilemode.
{{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 itscondition_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:
- The conditional node function evaluates the condition and writes a small marker into run state.
- A conditional edge attached to the node reads that marker and returns the id of the next node.
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 aconditional_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 anExpressionBranch: 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:- The
sourcereference is resolved to its run value. Avaluecontaining{{...}}is resolved too. - Type coercion runs before comparison. If both sides look like integers, both are parsed as
int; if both look like floats, both asfloat; otherwise both stay strings. This makesgreater_thanand friends behave numerically when the data is numeric, and lexically when it is not. - The operator is applied.
equalsandnot_equalsare lenient — they also compare the string forms, so5and"5"are treated as equal.contains,starts_with, andends_withalways operate on string forms.is_emptyis true forNone,"", and[]. - On the first true branch, the engine stores
{matched_target: <branch.target>}and stops checking. - If no branch matched and
default_targetis set,matched_targetbecomes the default.
Operator reference
Python expression mode
When a comparison is more than the operators allow, setexpression 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:
"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
Anllm 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:
- Resolves
{{...}}references inprompt_templateagainst state. - Calls the model once with that prompt.
- Stores the model’s trimmed text answer in state as
llm_routing_decision. - The routing edge maps that answer to a target node via
routes.
default_target) for answers that do not match. Keep temperature low for deterministic routing.
Loops
Inloop 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 byloop_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:- The previous node connects to the controller.
- The controller initializes loop state on the first call, then increments the iteration and accumulates results on each subsequent call.
- A routing edge on the controller decides
body_target(continue) orexit_target(done), based on the mode’s condition andmax_iterations. body_end(orbody_target) cycles back to the controller to close the loop.
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.
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:- Expression branches
- LLM decision
- Loop
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.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.{{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 anexpression 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.
{"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:
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 aloop 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)
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}}.
Related
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.