Skip to main content
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. For the per-node configuration that produces each output, see the node types 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:
1

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

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

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 for the full loop model.
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.
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.

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.
object
The workflow’s declared state. Contains exactly one key.
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.
Each entry under fields is a StateField with the following shape:
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.
string
An optional human-readable description of the field. Documentation only; it does not affect resolution.
string
default:"none"
How concurrent or repeated writes to this field merge. One of none, add, or update. Defaults to none (replace). See Reducers below.
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).
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 for the exact precedence rules.
A minimal state_schema with one declared input field and one accumulator field looks like this:
state_schema example
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.

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

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.
pure reference (type preserved)
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.
template string (interpolated)
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.

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:
Reading a key that does not exist returns null. Example: {{node_a.missing}} when node_a has no missing key.
An index past the end of a list returns null. Example: {{node_a.results[99]}} on a 3-item list.
A bracket index against a value that is not a list or tuple returns null.
A dot segment against a value that is not a dict returns null.
A path with an unclosed bracket (for example results[0) returns null.
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 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.
input_mapping behaviors
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.
array spread vs plain item
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:
reducer
default:"default"
Replace. Each write overwrites the previous value. This is the default and the right choice for most fields.
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.
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.
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.

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

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

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

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

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

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.
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 and run via API.

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):
node_update frames (SSE wire)
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.

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

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 for the full cost model.

Workflow engine & nodes

How a workflow definition compiles to a graph, the state class, and the reference resolver.

Node types overview

The nine node types and what each writes into run state.

SSE run streaming

The event frames that carry each node’s output during a run.

Error handling & retries

How node failures surface and how to debug a failed run.