Variables & Data Mapping

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-engine/src/executor/workflow_executor/node_execution.rs, crates/cheng-engine/src/executor/context.rs, crates/cheng-nodes/src/nodes/builtin/utils/smart_variable_node.rs, chengflow-ui/src/features/editor/components/VariableSelectorDrawer.tsx

Data moves between nodes in three ways: edges (the primary mechanism), ${...} substitution inside a node's own config, and ambient injection by the engine. They have a strict precedence order, and knowing it explains almost every "why did my node get that value" question.

Edges

An edge names four things: source node, source output port, target node, target input port.

Node A ──[ text ]────────►[ user_message ] Node B
        source_output       target_input

At execution time the engine walks every incoming edge of a node, reads the source node's output for that port, and writes it into the target node's input under the target port's name. Ports are matched by name, so renaming an output port in a node's schema breaks any edge that referenced the old name.

Dynamic array ports

A port named prefix_N resolves against an array output when no port of that exact name exists. The engine tries prefix_outputs[N] first, then prefixs[N]:

branch_0  →  branch_outputs[0]   (tried first)
          →  branches[0]         (fallback)

This is what lets a router node declare one array output and still expose numbered ports on the canvas.

Null means "don't take this path"

A node whose incoming edges all produce null is auto-skipped rather than run with empty inputs. That is the mechanism behind conditional routing: a condition node emits a value on the branch it selects and null on the others, and the unselected downstream subtrees skip themselves. Skips cascade — skipping a node can unblock the skip check for nodes further down.

Input precedence

When a node runs, its inputs are assembled in this order. Later steps do not overwrite earlier ones unless explicitly noted.

#SourceOverwrites?
1Incoming edges— (first writer)
2Flat execution inputs (start nodes only, no incoming edges)Fills gaps only
3Loop virtual inputs (item, index, previous-iteration value)Fills gaps only
4The node's own config, after ${...} substitutionFills gaps only
5Execution-time inputs keyed by node idOverwrites
6AgentContext into contextOnly if context is unset
7Ambient fields (workspace_id, conversation_id, document_id)Fills gaps only

Two consequences people trip over:

Step 6 is a real bug guard: if an edge already supplied context — a RAG retriever's results, say — the engine will not clobber it with the agent context.

${...} substitution

Inside a node's config, ${name} is replaced with the execution input called name:

{ "url": "https://api.example.com/v1/${resource}",
  "header": "Bearer ${api_token}" }

The rules are deliberately narrow:

__env__-prefixed keys carry skill-resolved secrets. They are available to ${__env__KEY} substitution but are never exposed as direct node inputs, because node inputs get persisted alongside outputs and a secret would end up in the execution record.

Ambient fields

workspace_id, conversation_id, and document_id are injected by the engine from the agent context or execution metadata. On the canvas these inputs are hidden and normally left blank.

For tool calls the injection is a force-override, not a gap-fill: whatever the model put in the field is replaced. A model cannot widen its own scope by inventing a workspace_id. See Knowledge Bases & Workspace Context.

Compatibility shims worth knowing

The Smart Variable node

utils/smart_var exists for the case template substitution handles badly: you have a blob of JSON, a curl command, or a YAML snippet, and you want its fields to become wired inputs.

Paste the text into template, and the node extracts variable names and generates one input port per variable. Connect the ports you care about; the node emits the fully substituted text.

Extraction modes:

ModeBehaviour
auto (default)Detects key names — parses the text as JSON and walks it recursively; falls back to a regex scan when it is not valid JSON
manualOnly {{var}} placeholders

Duplicate names get an index suffix (token, token_2, …) so two fields with the same key become two distinct ports rather than silently colliding in a map. Meaningless words are filtered out of the candidate list.

Outputs:

That statistics block is the debugging tool: missing_variables greater than zero tells you a port is unconnected before you ever look at the output text.

The variable picker

output_vars is edited through a drawer, not a text field: search across names and descriptions, tick individually, or select/deselect everything currently matching the filter. It shows selected-versus-total counts, so on a 60-field payload you can see at a glance how much you have picked.

Choosing a mechanism

SituationUse
One node's output feeds another's inputAn edge
A fixed config string needs one runtime value${...} in the config
A text blob has many fields to wireutils/smart_var
A node needs the current workspace or conversationNothing — it is ambient

Next steps