> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Conditional node: branching & loops

> Branch a workflow on visual expression conditions, a Python expression, or an LLM decision — and run FOR, FOREACH, and WHILE loops. Full ConditionalNodeConfig reference, operator list, edge routing, run-state outputs, credit impact, errors, and a worked example.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

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](/concepts/workflow-engine), 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](/concepts/workflow-engine). For the `{{node_id.field}}` reference system used throughout this page, see [Variables & references](/workflow-builder/variables-and-references).

<MediaEmbed id="MX-MEDIA-3120" type="screenshot" caption={"the conditional node configuration panel in the workflow builder"} />

## 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](/concepts/workflow-engine).
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](/realtime/sse-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.

<ParamField path="condition_type" type="string" required>
  One of `expression`, `llm`, or `loop`. Required. Determines which other fields are read and how the node routes.
</ParamField>

<ParamField path="expression_branches" type="ExpressionBranch[]">
  Visual branches for `expression` routing. Evaluated **in order**; the first branch whose comparison is true wins. See [Expression branches](#expression-branches-visual-routing). Used only when `condition_type` is `expression`.
</ParamField>

<ParamField path="expression" type="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](#python-expression-mode)). Used only when `condition_type` is `expression`.
</ParamField>

<ParamField path="routes" type="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`.
</ParamField>

<ParamField path="default_target" type="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`.
</ParamField>

<ParamField path="llm" type="LLMConfig">
  The model configuration for `condition_type: llm`. Required for that type. Same `LLMConfig` shape used by the [LLM node](/workflow-builder/nodes/llm): `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](/integrations/llm-providers/overview).
</ParamField>

<ParamField path="prompt_template" type="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.
</ParamField>

<ParamField path="loop_config" type="LoopConfig">
  The loop definition for `condition_type: loop`. Required for that type. See [Loops](#loops) for every field.
</ParamField>

<Note>
  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.
</Note>

### Validation

The config validator enforces these rules when the workflow is saved or run:

| `condition_type` | Required fields                                         | Error if missing                                                                        |
| ---------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `expression`     | one of `expression`, `expression_branches`, or `routes` | `condition_type='expression' requires 'expression', 'expression_branches', or 'routes'` |
| `llm`            | `routes` (and `llm` to actually call a model)           | `condition_type='llm' requires 'routes'`                                                |
| `loop`           | `loop_config`                                           | `condition_type='loop' requires 'loop_config'`                                          |

These surface as a `422` validation error on the workflow save/update routes (the `{detail}` `HTTPException` shape — see [Errors & status codes](/api-reference/errors)), 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.

<ParamField path="id" type="string" required>
  A unique branch identifier (used for logging and the canvas).
</ParamField>

<ParamField path="source" type="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.
</ParamField>

<ParamField path="operator" type="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`.
</ParamField>

<ParamField path="value" type="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.
</ParamField>

<ParamField path="target" type="string" required>
  The node id to route to when this branch's comparison is true.
</ParamField>

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

```json theme={null}
{ "node_route_1": { "matched_target": "node_send_email" } }
```

<Warning>
  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.
</Warning>

### Operator reference

| Operator                 | True when                                       | Compared as                          |
| ------------------------ | ----------------------------------------------- | ------------------------------------ |
| `equals`                 | `source == value` (or their string forms match) | coerced, then string-lenient         |
| `not_equals`             | `source != value` and string forms differ       | coerced, then string-lenient         |
| `greater_than`           | `source > value`                                | numeric if both numeric, else string |
| `less_than`              | `source < value`                                | numeric if both numeric, else string |
| `greater_than_or_equals` | `source >= value`                               | numeric if both numeric, else string |
| `less_than_or_equals`    | `source <= value`                               | numeric if both numeric, else string |
| `contains`               | `value` is a substring of `source`              | string                               |
| `not_contains`           | `value` is not a substring of `source`          | string                               |
| `starts_with`            | `source` starts with `value`                    | string                               |
| `ends_with`              | `source` ends with `value`                      | string                               |
| `is_empty`               | `source` is `None`, `""`, or `[]`               | n/a (`value` ignored)                |
| `is_not_empty`           | `source` is not `None`, `""`, or `[]`           | n/a (`value` ignored)                |

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

```json theme={null}
{
  "condition_type": "expression",
  "expression": "'high' if state.get('node_score', {}).get('value', 0) >= 80 else 'low'",
  "routes": { "high": "node_escalate", "low": "node_close" }
}
```

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).

<Note>
  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.
</Note>

## 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`.

```json theme={null}
{
  "condition_type": "llm",
  "llm": {
    "integration_name": "modulexai",
    "provider_id": "modulexai",
    "model_id": "claude-haiku-3.5"
  },
  "prompt_template": "Classify this support message as exactly one word: billing, technical, or other.\n\nMessage: {{input.message}}",
  "routes": {
    "billing": "node_billing_queue",
    "technical": "node_tech_queue",
    "other": "node_general_queue"
  }
}
```

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.

<Warning>
  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.
</Warning>

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

<ParamField path="loop_id" type="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).
</ParamField>

<ParamField path="mode" type="string" required>
  One of `for`, `foreach`, or `while`.
</ParamField>

<ParamField path="iterations" type="integer">
  FOR mode: the number of iterations. Provide this or `iterations_ref`.
</ParamField>

<ParamField path="iterations_ref" type="string">
  FOR mode: a `{{...}}` reference resolved to the iteration count at run time, for example `{{input.count}}`. Provide this or `iterations`.
</ParamField>

<ParamField path="collection" type="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.
</ParamField>

<ParamField path="parallel" type="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.
</ParamField>

<ParamField path="condition" type="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`.
</ParamField>

<ParamField path="max_iterations" type="integer" default="100">
  Safety cap. The loop force-exits once `iteration` reaches this, regardless of mode. Protects against runaway WHILE conditions.
</ParamField>

<ParamField path="body_target" type="string" required>
  The first node of the loop body. Each iteration enters here.
</ParamField>

<ParamField path="body_end" type="string">
  The last node of the loop body, which cycles back to the controller. Defaults to `body_target` (a single-node body).
</ParamField>

<ParamField path="exit_target" type="string" default="__end__">
  Where to go when the loop finishes. Defaults to `__end__` (terminate the run). An empty value is normalized to `__end__`.
</ParamField>

<ParamField path="accumulate" type="boolean" default="true">
  Collect each iteration's output into `{{loop_id}}_results`. When `true`, the controller appends a value every iteration.
</ParamField>

<ParamField path="accumulate_from" type="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.
</ParamField>

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

| Field                         | Type    | Modes                 | Meaning                            |
| ----------------------------- | ------- | --------------------- | ---------------------------------- |
| `{{loop_id}}_iteration`       | integer | all                   | Current iteration index (0-based). |
| `{{loop_id}}_initialized`     | boolean | all                   | Whether the loop has started.      |
| `{{loop_id}}_completed`       | boolean | all                   | Whether the loop has finished.     |
| `{{loop_id}}_item`            | any     | foreach               | The current item.                  |
| `{{loop_id}}_index`           | integer | foreach               | The current item's index.          |
| `{{loop_id}}_collection_size` | integer | foreach               | The resolved collection length.    |
| `{{loop_id}}_results`         | array   | all (if `accumulate`) | Accumulated per-iteration values.  |

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](/concepts/workflow-engine).

<Warning>
  **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`.
</Warning>

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

<Tabs>
  <Tab title="Expression branches">
    The engine adds a conditional edge whose path map is the set of all branch `target`s 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.
  </Tab>

  <Tab title="LLM decision">
    The decision edge uses the `routes` map as its path map. The routing function returns the model's trimmed answer; the engine maps that answer to the target via `routes`. An answer that is not a key has no target and ends the run.
  </Tab>

  <Tab title="Loop">
    The controller's edge has a two-entry path map: `body_target` and `exit_target`. The routing function re-checks the mode's condition each iteration and returns one of the two.
  </Tab>
</Tabs>

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.

<Note>
  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](/reference/known-limitations).
</Note>

## 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](/workflow-builder/variables-and-references).

**Outputs.** What lands in state depends on the type:

<ResponseField name="expression conditional" 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.

  <Expandable title="state entry">
    <ResponseField name="<node_id>.matched_target" type="string">
      The id of the node the run will route to.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="llm conditional" type="object">
  Writes `{llm_routing_decision: "<text>"}` at the top level of state — not under the node id.

  <Expandable title="state entry">
    <ResponseField name="llm_routing_decision" type="string">
      The model's trimmed answer, matched against `routes` by the edge.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="loop controller" type="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.
</ResponseField>

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](/billing/credits).

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](/billing/usage-gating) and [Errors & status codes](/api-reference/errors).

## Errors

| Situation                                              | Where it surfaces             | Shape                                                                       |
| ------------------------------------------------------ | ----------------------------- | --------------------------------------------------------------------------- |
| Missing required field for the chosen `condition_type` | Workflow save/update (`422`)  | `{detail}` `HTTPException` — see [Errors](/api-reference/errors)            |
| No branch matched and no `default_target`              | Run time                      | Edge falls back to ending the run (no error)                                |
| LLM answer is not a `routes` key                       | Run time                      | No matching target; edge ends the run                                       |
| Python `expression` raises                             | Run time                      | Logged; no `matched_target` written; falls back to `default_target` or ends |
| WHILE `condition` raises                               | Run time                      | Logged; loop exits to `exit_target`                                         |
| LLM condition transient failure                        | Run time                      | Retried per `retry_config`; `node_retry` then `node_error` on final failure |
| Credit/usage denial during an LLM condition            | Run time                      | `node_error` with `reason` = error `code` (for example `credit_exhausted`)  |
| Run started while over a limit                         | Run start (`402`/`403`/`429`) | Flat `DenialEnvelope` — see [Usage gating](/billing/usage-gating)           |

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](/workflow-builder/nodes/llm) that writes a `label` field; the conditional routes on it.

<CodeGroup>
  ```json Workflow definition (excerpt) theme={null}
  {
    "metadata": { "name": "Support triage", "version": "1.0" },
    "state_schema": {
      "fields": {
        "message": { "type": "string", "required": true }
      }
    },
    "nodes": [
      {
        "id": "node_classify",
        "type": "llm",
        "x": 0, "y": 0,
        "llm_config": {
          "llm": { "integration_name": "modulexai", "provider_id": "modulexai", "model_id": "claude-haiku-3.5" },
          "system_prompt": "Classify the message. Reply with JSON: {\"label\": \"billing|technical|other\"}.",
          "user_prompt": "{{input.message}}",
          "structured_output_schema": {
            "type": "object",
            "properties": { "label": { "type": "string" } },
            "required": ["label"]
          }
        }
      },
      {
        "id": "node_route",
        "type": "conditional",
        "x": 240, "y": 0,
        "conditional_config": {
          "condition_type": "expression",
          "expression_branches": [
            { "id": "b_billing",   "source": "{{node_classify.label}}", "operator": "equals", "value": "billing",   "target": "node_billing_queue" },
            { "id": "b_technical", "source": "{{node_classify.label}}", "operator": "equals", "value": "technical", "target": "node_tech_queue" }
          ],
          "default_target": "node_general_queue"
        }
      }
    ],
    "edges": [
      { "source": "__start__", "target": "node_classify" },
      { "source": "node_classify", "target": "node_route" }
    ]
  }
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.modulex.dev/workflows/run" \
    -H "Authorization: Bearer mx_live_XXXXXXXXXXXXXXXX" \
    -H "X-Organization-ID: org_XXXXXXXXXXXX" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "wf_XXXXXXXXXXXX",
      "input": { "message": "I was charged twice this month." }
    }'
  ```

  ```python Python theme={null}
  from modulex import ModuleX

  client = ModuleX(
      api_key="mx_live_XXXXXXXXXXXXXXXX",
      organization_id="org_XXXXXXXXXXXX",
  )

  run = await client.workflows.run(
      workflow_id="wf_XXXXXXXXXXXX",
      input={"message": "I was charged twice this month."},
  )
  print(run)
  ```

  ```javascript JavaScript theme={null}
  import { ModuleX } from "modulex";

  const client = new ModuleX({
    apiKey: "mx_live_XXXXXXXXXXXXXXXX",
    organizationId: "org_XXXXXXXXXXXX",
  });

  const run = await client.workflows.run({
    workflowId: "wf_XXXXXXXXXXXX",
    input: { message: "I was charged twice this month." },
  });
  console.log(run);
  ```
</CodeGroup>

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:

```json theme={null}
{ "node_route": { "matched_target": "node_billing_queue" } }
```

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](/realtime/sse-streaming); to run a workflow end to end, see [Run a workflow](/guides/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:

```json Conditional node (loop mode) theme={null}
{
  "id": "node_loop",
  "type": "conditional",
  "x": 240, "y": 0,
  "conditional_config": {
    "condition_type": "loop",
    "loop_config": {
      "loop_id": "items",
      "mode": "foreach",
      "collection": "{{node_fetch.records}}",
      "body_target": "node_process",
      "exit_target": "node_summarize",
      "accumulate": true,
      "accumulate_from": "{{node_process}}"
    }
  }
}
```

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}}`.

## Related

<CardGroup cols={2}>
  <Card title="Node types reference" icon="grip" href="/workflow-builder/nodes/overview">
    All nine node types and the state convention they share.
  </Card>

  <Card title="Workflow engine & nodes" icon="cpu" href="/concepts/workflow-engine">
    How edges, loops, and state are compiled and run.
  </Card>

  <Card title="Variables & references" icon="braces" href="/workflow-builder/variables-and-references">
    The `{{node_id.field}}` reference system used by every condition.
  </Card>

  <Card title="Guardrails node" icon="shield-check" href="/workflow-builder/nodes/guardrails">
    Validate content and route on the result, complementing conditional routing.
  </Card>
</CardGroup>
