{{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 dynamicdict 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.{{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.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.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 ownid. 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.
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 {{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)
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)
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 theresultskey. - 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}}.
null (not an error) in every one of these cases:
Missing dict key
Missing dict key
Reading a key that does not exist returns
null. Example: {{node_a.missing}} when node_a has no missing key.Out-of-range list index
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.Indexing a non-list
Indexing a non-list
A bracket index against a value that is not a list or tuple returns
null.Key access on a non-dict
Key access on a non-dict
A dot segment against a value that is not a dict returns
null.Malformed bracket
Malformed bracket
A path with an unclosed bracket (for example
results[0) returns null.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
nullor""(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
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).
{{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
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 withreducer 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 declaredstate_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.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 atopic 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
{{topic}}reads the declaredstate_schemafield, supplied by the run input.- The
functionnode writes itshttp_requestresult undernode_fetch_1. Forhttp_request, thedatais{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 keybody, dict keyresults, list index0, dict keytitle. If any segment is missing, it resolves tonulland the literal token stays in the prompt.- The
user_promptis 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_2writes the model’s content undernode_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 withAuthorization: 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 anode_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)
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 tonull or leaves the literal token, as described above. The errors you will actually encounter come from the surrounding execution:
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:
llmandagentnodes record token usage per model call (managedmodulexaiusage is billed in credits; BYOK is not credited).knowledgenodes 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).
function, transformer, conditional, interrupt, or guardrails nodes. See credits & metering for the full cost model.
Related
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.