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.
| # | Source | Overwrites? |
|---|---|---|
| 1 | Incoming edges | — (first writer) |
| 2 | Flat execution inputs (start nodes only, no incoming edges) | Fills gaps only |
| 3 | Loop virtual inputs (item, index, previous-iteration value) | Fills gaps only |
| 4 | The node's own config, after ${...} substitution | Fills gaps only |
| 5 | Execution-time inputs keyed by node id | Overwrites |
| 6 | AgentContext into context | Only if context is unset |
| 7 | Ambient fields (workspace_id, conversation_id, document_id) | Fills gaps only |
Two consequences people trip over:
- An edge always beats the field you typed into the property panel. Config is step 4; edges are step 1. A connected input is not editable in the panel for exactly this reason.
- Per-node execution inputs beat everything. That is what makes single-node testing work: the test runner supplies inputs keyed by node id and they override the wired graph.
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:
- Only strings are substituted, recursively through objects and arrays.
- Only simple values interpolate — string, number, boolean. An object or array value leaves the placeholder untouched.
- An unknown name leaves
${name}in place rather than substituting an empty string. A missing value therefore shows up as a visible placeholder in the request you sent, not as a silent blank.
__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
ai/llmandagent/llmfall back toraw_inputwhenuser_messageis empty. Gateway and chat entry points pass the user's text asraw_input, so a model node works when wired directly to a chat input without a mapping node.- An execution input keyed by node id that is not an object is written to
textforio/input_text, and tocontentfor everything else.
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:
| Mode | Behaviour |
|---|---|
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 |
manual | Only {{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:
full_text— the template with every variable substituted; this is what downstream nodes usually consume.selected_vars— only the variables you ticked inoutput_vars, as a key-value map.all_variables— every name found, which is what populates the picker.preview_content— a formatted preview for the canvas.statistics— total / connected / defaulted / missing variable counts, plus template and output sizes.
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
| Situation | Use |
|---|---|
| One node's output feeds another's input | An edge |
| A fixed config string needs one runtime value | ${...} in the config |
| A text blob has many fields to wire | utils/smart_var |
| A node needs the current workspace or conversation | Nothing — it is ambient |
Next steps
- Node Configuration & Schema-Driven Forms — where config values come from.
- Subflows & Batch Subflows — mapping across a nested execution.
- Execution Model — when inputs are assembled relative to scheduling.