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

# Variables, references & run state

> Pass data between workflow nodes with the {{nodeId.path}} reference syntax, the state_schema, per-node output keys, reducers, and run-state defaults — with a full 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>;
};

A workflow run is driven by a single shared object: the **run state**. Every node reads the values it needs out of state and writes its result back into state, and you wire nodes together by referencing those values with the `{{nodeId.path}}` syntax. This page is the complete reference for that data-passing model: how state is built from your `state_schema`, where each node writes its output, how `{{nodeId.path}}` references resolve, how reducers and defaults behave, and a full worked example you can run.

For the broader picture of how a workflow is compiled and executed, see [the workflow engine](/concepts/workflow-engine). For the per-node configuration that produces each output, see the [node types overview](/workflow-builder/nodes/overview).

## The run state model

When a run starts, the engine builds a single state object — internally a dynamic `dict` subclass called `DynamicState` — and threads it through every node. State is the only channel nodes use to communicate: there are no direct node-to-node arguments. A node returns a partial update, the engine merges that update into state (subject to the field's reducer), and the next node reads whatever it needs out of the merged state.

State is assembled from three sources, in this order:

<Steps>
  <Step title="Your declared state fields">
    Every field you define in the workflow's `state_schema.fields` becomes a state field, with the type, reducer, and default you declared. These are the fields you control.
  </Step>

  <Step title="One field per node id">
    The engine automatically adds **one state field for every node id** in the workflow, typed as `Any`. This is how a node's output becomes referenceable — `{{node_summary_1}}` resolves to the value the node `node_summary_1` wrote. You never declare these; they exist for every node and are required for output streaming to work.
  </Step>

  <Step title="Loop state fields">
    If a workflow contains loops, the engine adds bookkeeping fields per loop (for example `{loop_id}_iteration`, `{loop_id}_index`, `{loop_id}_results`). Loops are out of scope here — see [the workflow engine](/concepts/workflow-engine) for the full loop model.
  </Step>
</Steps>

<Note>
  A node id collision is impossible to declare around: if a `state_schema` field and a node id share a name, the declared field wins (the per-node field is only added when the id is `not in` the existing annotations). Keep node ids distinct from your declared field names to avoid surprises.
</Note>

The MDX-safe way to think about a reference is: `{{nodeId}}` reads the field named `nodeId` from state, and `{{nodeId.path.to.value}}` walks into the value stored there. Because every node id is a state field, **the output of any prior node is always referenceable by its id**.

<MediaEmbed id="MX-MEDIA-3030" type="image" caption={"Run-state data-flow diagram showing nodes reading from and writing to a single shared state object."} />

## The state\_schema

`state_schema` is the part of a workflow definition where you declare your own state fields. It is a single object with one key, `fields`, mapping each field name to a field definition.

<ParamField path="state_schema" type="object">
  The workflow's declared state. Contains exactly one key.
</ParamField>

<ParamField path="state_schema.fields" type="object" required>
  A map of field name to a `StateField` definition. Required (the `StateSchema` model requires `fields`). May be an empty object `{}` if your workflow only passes data via node-id references.
</ParamField>

Each entry under `fields` is a `StateField` with the following shape:

<ParamField path="type" type="string" required>
  The field's type. One of `string`, `integer`, `float`, `boolean`, `object`, `array`, or `messages`. These map to Python `str`, `int`, `float`, `bool`, `dict`, `list`, and `list` respectively. `messages` is a list specialized for conversational message arrays.
</ParamField>

<ParamField path="description" type="string">
  An optional human-readable description of the field. Documentation only; it does not affect resolution.
</ParamField>

<ParamField path="reducer" type="string" default="none">
  How concurrent or repeated writes to this field merge. One of `none`, `add`, or `update`. Defaults to `none` (replace). See [Reducers](#reducers) below.
</ParamField>

<ParamField path="required" type="boolean" default="false">
  Whether the field is required. Defaults to `false`. This is a declaration flag on the schema; the engine still applies a type-appropriate empty value when a non-required field is absent (see [Defaults & overrides](#defaults--overrides)).
</ParamField>

<ParamField path="default" type="any" default="null">
  The default value applied at run start when the run input does not supply this field. Defaults to `null`. See [Defaults & overrides](#defaults--overrides) for the exact precedence rules.
</ParamField>

A minimal `state_schema` with one declared input field and one accumulator field looks like this:

```json state_schema example theme={null}
{
  "state_schema": {
    "fields": {
      "topic": {
        "type": "string",
        "description": "The subject the run operates on",
        "reducer": "none",
        "required": true,
        "default": null
      },
      "collected_titles": {
        "type": "array",
        "description": "Titles gathered across steps",
        "reducer": "add",
        "required": false,
        "default": []
      }
    }
  }
}
```

<Note>
  You do not need to declare a field for every value you pass between nodes. Because every node id is already a state field, node-to-node hand-offs work with **no `state_schema` entries at all**. Declare fields in `state_schema` when you want a named run input, a non-default reducer (accumulation/merging), or an explicit default.
</Note>

## Per-node output keys

The output convention is uniform across all nine node types: **every node writes its result to state under its own `id`**. A node whose id is `node_llm_2` writes its result to `state["node_llm_2"]`, and you read it back with `{{node_llm_2}}`. There is no per-node "output name" you have to configure — the id is the key.

<Warning>
  Most node configs still expose an `output_key` field. It is **deprecated** and ignored — output is always stored under the node id. Do not rely on `output_key` to redirect a node's result; it will not change where the value lands in state.
</Warning>

The exact shape stored under the node id depends on the node type. The table below is the per-node output contract, so you know what `{{nodeId.path}}` paths are available downstream.

| Node type     | Stored under `{{nodeId}}`                                                                   | Notable sub-paths                                                                                                                 | Reference                                               |
| ------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `llm`         | The model's content (a string, or the parsed object when `structured_output_schema` is set) | object keys when structured output is used                                                                                        | [LLM node](/workflow-builder/nodes/llm)                 |
| `tool`        | The unwrapped tool result                                                                   | the `{success, action, result}` wrapper is flattened to root; `result` is also kept for backward-compatible `.result.field` paths | [Tool node](/workflow-builder/nodes/tool)               |
| `agent`       | The agent's final content after its tool loop                                               | —                                                                                                                                 | [Agent node](/workflow-builder/nodes/agent)             |
| `function`    | On success, the function's `data`; on failure, `{error, success: false, ...}`               | for `http_request`: `.status_code`, `.headers`, `.body`, `.url`                                                                   | [Function node](/workflow-builder/nodes/function)       |
| `conditional` | For expression branching: `{matched_target}`; for LLM routing: `{llm_routing_decision}`     | `.matched_target`, `.llm_routing_decision`                                                                                        | [Conditional node](/workflow-builder/nodes/conditional) |
| `interrupt`   | The resume value supplied when the run continues                                            | —                                                                                                                                 | [Interrupt node](/workflow-builder/nodes/interrupt)     |
| `transformer` | The transformed value after all operations                                                  | —                                                                                                                                 | [Transformer node](/workflow-builder/nodes/transformer) |
| `guardrails`  | A result dict                                                                               | `.valid`, `.validations`, `.transformed_data`, `.pii_findings`, `.route_to`                                                       | [Guardrails node](/workflow-builder/nodes/guardrails)   |
| `knowledge`   | A formatted retrieval result                                                                | `.context`, `.chunks`, `.total_results` (per the node's `output_format`)                                                          | [Knowledge node](/workflow-builder/nodes/knowledge)     |

<Tip>
  The `tool` node flattens the integration wrapper, so the same value is reachable two ways: `{{node_tool_1.field}}` (flattened) and `{{node_tool_1.result.field}}` (legacy). Prefer the flattened path in new workflows.
</Tip>

## The `{{nodeId.path}}` reference syntax

A reference is a string of the form `{{nodeId.path}}`. The engine resolves references wherever a node config accepts a templated value — for example an `llm` node's `user_prompt`, a `tool` node's `input_mapping`, a `knowledge` node's `query`, or a `conditional` branch's `source`.

Resolution has three distinct behaviors depending on where the reference appears.

### Pure reference — type preserved

When a value is **exactly** a single reference (the string starts with `{{` and ends with `}}` and contains nothing else), the engine returns the **native typed value** from state — not a string. So `{{node_http_1.body}}` passed as a `tool` input yields the actual object/array/number stored there, with its type intact.

```json pure reference (type preserved) theme={null}
{ "payload": "{{node_http_1.body}}" }
```

If `node_http_1.body` is an object, `payload` receives that object — not its string form.

### Template string — interpolated to text

When a reference is embedded inside a larger string (text around it, or more than one reference), the engine performs **string interpolation**: every `{{...}}` is replaced inline and the result is always a string. Object and array values are JSON-encoded (`json.dumps`) when interpolated this way.

```text template string (interpolated) theme={null}
Summarize the article titled "{{node_fetch_1.results[0].title}}" for topic {{topic}}.
```

<Warning>
  An unresolved reference inside a template string is **left intact** — the literal `{{...}}` text remains in the output rather than becoming empty. If you see raw `{{...}}` tokens in a node's input or an LLM prompt, the path did not resolve; check the node id and the path segments.
</Warning>

### Path traversal

The `.path` after the node id walks into the stored value using dot segments and bracket indices:

* Dot segments read dict keys: `{{node_a.results}}` reads the `results` key.
* Bracket indices read list/tuple positions: `{{node_a.results[0]}}` reads the first element.
* The two combine to any depth: `{{node_a.results[0].title}}`.

Path traversal is **silent on failure** — it returns `null` (not an error) in every one of these cases:

<AccordionGroup>
  <Accordion title="Missing dict key">
    Reading a key that does not exist returns `null`. Example: `{{node_a.missing}}` when `node_a` has no `missing` key.
  </Accordion>

  <Accordion title="Out-of-range list index">
    An index past the end of a list returns `null`. Example: `{{node_a.results[99]}}` on a 3-item list.
  </Accordion>

  <Accordion title="Indexing a non-list">
    A bracket index against a value that is not a list or tuple returns `null`.
  </Accordion>

  <Accordion title="Key access on a non-dict">
    A dot segment against a value that is not a dict returns `null`.
  </Accordion>

  <Accordion title="Malformed bracket">
    A path with an unclosed bracket (for example `results[0`) returns `null`.
  </Accordion>
</AccordionGroup>

Because resolution is silent, a wrong path does not fail the run — it produces `null` (in a pure reference) or leaves the literal token (in a template string). Validate paths against the [per-node output contract](#per-node-output-keys) above rather than relying on a runtime error.

### Reference resolution in input mappings

`tool`, `agent`, `function`, and `transformer` nodes resolve a whole `input_mapping` dict. Two behaviors matter when you build one:

* **Empty values are dropped.** A mapping key whose value is `null` or `""` (the empty string the builder sends for a cleared field) is **skipped entirely** — the key is not passed to the tool/function. Use this to omit optional parameters; do not rely on receiving an empty string downstream.
* **Mixed-type resolution.** A pure-reference value keeps its native type; a template-string value resolves to text; nested dicts recurse; lists resolve per item.

```json input_mapping behaviors theme={null}
{
  "query":   "{{node_fetch_1.topic}}",
  "limit":   10,
  "context": "About {{topic}}",
  "extra":   ""
}
```

In this mapping `query` keeps the resolved type, `limit` passes through as the literal `10`, `context` resolves to a string, and `extra` is **dropped** because it is empty.

### Array spread

Inside a **list item**, the special form `{{...nodeId.path}}` (a leading triple-dot inside the braces) is an opt-in **spread**:

* If the referenced value is a list, its elements are **spliced** into the surrounding list.
* If it is a scalar or dict, it is appended as a single element.
* If it resolves to `null`, it contributes **nothing** (the item is skipped).

A plain `{{ref}}` list item does **not** spread — its resolved value sits in the list as one element even if that value is itself a list (this preserves backward compatibility). The spread marker only has meaning as a whole list item; in any other position (a pure reference or inside a template string) the leading `...` is stripped and the reference behaves exactly like the plain form.

```json array spread vs plain item theme={null}
{
  "ids": [ "{{...node_a.id_list}}", "manual-id", "{{node_b.single_id}}" ]
}
```

If `node_a.id_list` is `["x", "y"]` and `node_b.single_id` is `"z"`, the resolved `ids` is `["x", "y", "manual-id", "z"]` — the first item spread, the others appended.

## Reducers

A reducer decides how a write to a state field combines with the value already there. You set it per field with `reducer` in the `state_schema`. There are three:

<ParamField path="none" type="reducer" default="default">
  **Replace.** Each write overwrites the previous value. This is the default and the right choice for most fields.
</ParamField>

<ParamField path="add" type="reducer">
  **Smart append.** For `array` fields it appends — and auto-wraps a scalar write so that an array field plus `"x"` becomes `["x"]`. For other types it falls back to `operator.add` (numeric addition, string concatenation). Use this for accumulators that grow across loop iterations or fan-in branches.
</ParamField>

<ParamField path="update" type="reducer">
  **Dict merge.** Merges the incoming dict into the existing dict (`{**left, **right}`), with type-safety guards: a non-dict incoming value is wrapped under a `result` key, and a non-dict existing value is replaced. Use this to accumulate keys into an object field.
</ParamField>

<Note>
  Reducers matter most when more than one branch writes the same field (fan-in) or a loop body writes a field repeatedly. For a strictly linear, single-writer-per-field workflow, the default `none` is sufficient — each node writing under its own id never collides.
</Note>

## Defaults & overrides

At run start the engine computes the initial value of every declared `state_schema` field, then layers the run input on top. The precedence, exactly as applied:

<Steps>
  <Step title="Explicit default wins first">
    For each field, if its `default` is not `null`, that default is used as the field's starting value — regardless of whether the run input also supplies it (the default is placed into the defaults map unconditionally when non-null).
  </Step>

  <Step title="Type-appropriate empty for absent fields">
    If a field has no explicit default (`default` is `null`) **and** the field is not present in the run input, the engine fills a type-appropriate empty: `""` for `string`, `0` for `integer`, `0.0` for `float`, `false` for `boolean`, `{}` for `object`, `[]` for `array`, `[]` for `messages`.
  </Step>

  <Step title="Run input overrides">
    The run input is merged on top of the computed defaults (`{**defaults, **input}`), so any field your input supplies overrides the type-appropriate empty from step 2.
  </Step>
</Steps>

<Warning>
  The override interaction has one sharp edge: a field with a **non-null explicit `default`** is added to the defaults map unconditionally, then the run input is merged on top — so run input still overrides an explicit default. But a field whose `default` is non-null is **never** given the type-appropriate empty, even when input is absent. In short: explicit default, else (input value, else type-empty). Set `default: null` if you want a field to fall through to the type-appropriate empty when no input is given.
</Warning>

Fields that are not declared in `state_schema` — the auto-added per-node-id fields — are **not** seeded with defaults. They simply do not exist in state until the owning node runs and writes its result, at which point `{{nodeId}}` begins to resolve. Referencing a node that has not run yet resolves to `null`.

## Worked example

This workflow takes a `topic` input, fetches data over HTTP with a `function` node, summarizes it with an `llm` node that reads both the input and the fetch result, and exposes the summary as the run's final state. It exercises a declared input field, a per-node output key, a pure reference, a template-string reference, and path traversal.

### Workflow definition

<CodeGroup>
  ```json WorkflowDefinition theme={null}
  {
    "metadata": { "name": "Topic summarizer" },
    "config": { "recursion_limit": 500 },
    "state_schema": {
      "fields": {
        "topic": {
          "type": "string",
          "description": "Subject to summarize",
          "reducer": "none",
          "required": true,
          "default": null
        }
      }
    },
    "nodes": [
      {
        "id": "node_fetch_1",
        "type": "function",
        "name": "Fetch articles",
        "x": 0,
        "y": 0,
        "function_config": {
          "function_name": "http_request",
          "input_mapping": {
            "url": "https://api.example.com/articles?q={{topic}}",
            "method": "GET"
          }
        }
      },
      {
        "id": "node_summary_2",
        "type": "llm",
        "name": "Summarize",
        "x": 250,
        "y": 0,
        "llm_config": {
          "llm": { "provider_id": "modulexai", "model_id": "claude-haiku-3.5" },
          "system_prompt": "You are a concise analyst.",
          "user_prompt": "Topic: {{topic}}. Summarize this article titled \"{{node_fetch_1.body.results[0].title}}\": {{node_fetch_1.body}}"
        }
      }
    ],
    "edges": [
      { "source": "__start__", "target": "node_fetch_1" },
      { "source": "node_fetch_1", "target": "node_summary_2" }
    ]
  }
  ```
</CodeGroup>

Notes on what each reference does:

* `{{topic}}` reads the declared `state_schema` field, supplied by the run input.
* The `function` node writes its `http_request` result under `node_fetch_1`. For `http_request`, the `data` is `{status_code, headers, body, url}`, so `{{node_fetch_1.body}}` is the response body.
* `{{node_fetch_1.body.results[0].title}}` is path traversal: dict key `body`, dict key `results`, list index `0`, dict key `title`. If any segment is missing, it resolves to `null` and the literal token stays in the prompt.
* The `user_prompt` is a **template string** (text around the references), so all three references are interpolated to text; the object value of `{{node_fetch_1.body}}` is JSON-encoded inline.
* `node_summary_2` writes the model's content under `node_summary_2`, referenceable downstream as `{{node_summary_2}}`.

### State at each step

<Expandable title="Trace of the run state">
  | After                                  | State contains                                                                    |
  | -------------------------------------- | --------------------------------------------------------------------------------- |
  | Run start (input `topic: "AI agents"`) | `topic = "AI agents"` (declared field, from input)                                |
  | `node_fetch_1`                         | adds `node_fetch_1 = {status_code, headers, body, url}` (the `http_request` data) |
  | `node_summary_2`                       | adds `node_summary_2 = "<the summary string>"`                                    |

  The per-node fields (`node_fetch_1`, `node_summary_2`) are auto-added `Any` fields; they hold `null` until each node runs.
</Expandable>

### Running it

Run a saved deployment of this workflow and stream its events. The same operation is shown three ways. Authenticate with `Authorization: Bearer mx_live_…` plus `X-Organization-ID`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.modulex.dev/workflows/run" \
    -H "Authorization: Bearer mx_live_REDACTED" \
    -H "X-Organization-ID: org_123" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "wf_123",
      "input": { "topic": "AI agents" },
      "stream": true
    }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex

  async def main():
      client = Modulex(
          api_key="mx_live_REDACTED",
          organization_id="org_123",
      )
      res = await client.executions.run(
          workflow_id="wf_123",
          input={"topic": "AI agents"},
          stream=True,
      )
      run_id, thread_id = res.run_id, res.thread_id
      async for event in client.executions.listen(run_id):
          if event.event == "node_update":
              print(event.data)        # {"type": "node_update", "node": ..., "output": {...}}
          if event.event in ("done", "error", "cancelled"):
              break

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from 'modulex-js';

  const client = new Modulex({
    apiKey: 'mx_live_REDACTED',
    organizationId: 'org_123',
  });

  const run = await client.executions.run({
    workflowId: 'wf_123',
    input: { topic: 'AI agents' },
    stream: true,
  });

  for await (const evt of client.executions.listen(run.run_id)) {
    if (evt.type === 'node_update') console.log(evt.output); // { node_summary_2: "..." } shape
    if (evt.type === 'done') break;
  }
  ```
</CodeGroup>

<Note>
  Running a saved workflow by `workflow_id` requires an active deployment, or the call returns `400`. To run without deploying, pass an inline `workflow` (the full `WorkflowDefinition` above) instead of `workflow_id`. See [running workflows](/workflow-builder/execution/running) and [run via API](/workflow-builder/execution/api-endpoint).
</Note>

### Observing outputs on the stream

Each node's write surfaces as a `node_update` event on the run's SSE stream, and the field is keyed by node id. The wire shape is **flat** and uses `node` / `output` (not `node_id` / `status`):

```text node_update frames (SSE wire) theme={null}
data: {"type": "node_update", "node": "node_fetch_1", "output": {"node_fetch_1": {"status_code": 200, "body": {"results": [{"title": "..."}]}, "url": "..."}}}

data: {"type": "node_update", "node": "node_summary_2", "output": {"node_summary_2": "AI agents are ..."}}

data: {"type": "done", "data": {"message": "Workflow completed successfully"}}
```

The `output` object is the partial state update — the value under the node id is exactly what `{{nodeId}}` would resolve to downstream. For the complete event taxonomy and framing, see [SSE run streaming](/realtime/sse-streaming).

## Errors & failure modes

Reference resolution itself does not raise — it fails silently to `null` or leaves the literal token, as described above. The errors you will actually encounter come from the surrounding execution:

| Symptom                                             | Cause                                                                                 | What to do                                                                                                                                                                                             |
| --------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Literal `{{...}}` text appears in a prompt or input | The path did not resolve (wrong node id, wrong path segment, or the node had not run) | Check the node id and each path segment against the [per-node output contract](#per-node-output-keys). Remember a referenced node must run **before** the referencing node.                            |
| A downstream value is `null` unexpectedly           | Pure reference to a missing/out-of-range path                                         | Same as above; path traversal returns `null` silently.                                                                                                                                                 |
| An optional tool/function parameter is missing      | The mapping value was `null` or `""`, so it was dropped from `input_mapping`          | Supply a non-empty value, or accept that the parameter is intentionally omitted.                                                                                                                       |
| `400` on a saved-workflow run                       | No active deployment for that `workflow_id`                                           | Deploy the workflow first, or run an inline `workflow`. See [deploy & versions](/workflow-builder/execution/deploy).                                                                                   |
| `402` / `403` / `429` on a run                      | The billing admission gate denied the run — credit/quota/rate limit                   | The run surface returns a flat `DenialEnvelope` (`{code, layer, key, current, limit, reason}`). See [usage gating & limits](/billing/usage-gating) and [errors & status codes](/api-reference/errors). |
| A node returns `{error, success: false}`            | A `function` node's function failed (caught)                                          | Inspect the error sub-fields; downstream references to that node id will see the error object, not the expected `data`. See [error handling & retries](/workflow-builder/error-handling-retries).      |

<Warning>
  The `POST /workflows/run` surface is one of the surfaces the **billing gate is live on**. A run can be denied before it does any work with a `402` (credit), `403` (quota), or `429` (rate) — each carrying the flat `DenialEnvelope` shape rather than the plain `{detail}` shape used by CRUD routes. Handle these in any client that triggers runs. Full detail on [usage gating & limits](/billing/usage-gating).
</Warning>

## Credit impact

References and state mechanics are themselves free — resolving `{{nodeId.path}}`, applying reducers, and seeding defaults consume no credits. Credits are charged by the **work a node does**, not by data passing between nodes:

* **`llm` and `agent` nodes** record token usage per model call (managed `modulexai` usage is billed in credits; BYOK is not credited).
* **`knowledge` nodes** that use managed retrieval (`modulexdb`) reserve and record a small retrieval charge per query; BYOK knowledge providers are uncosted.
* The run itself is admitted through the credit gate at the start (see the billing note above).

There is no fixed per-node credit charge for `function`, `transformer`, `conditional`, `interrupt`, or `guardrails` nodes. See [credits & metering](/billing/credits) for the full cost model.

## Related

<CardGroup cols={2}>
  <Card title="Workflow engine & nodes" icon="diagram-project" href="/concepts/workflow-engine">
    How a workflow definition compiles to a graph, the state class, and the reference resolver.
  </Card>

  <Card title="Node types overview" icon="boxes-stacked" href="/workflow-builder/nodes/overview">
    The nine node types and what each writes into run state.
  </Card>

  <Card title="SSE run streaming" icon="wave-square" href="/realtime/sse-streaming">
    The event frames that carry each node's output during a run.
  </Card>

  <Card title="Error handling & retries" icon="triangle-exclamation" href="/workflow-builder/error-handling-retries">
    How node failures surface and how to debug a failed run.
  </Card>
</CardGroup>
