Template namespaces
A workflow node’s configuration may carry ${{ … }} tokens. Each token names a
namespace and a path inside it; the engine substitutes the referenced value
before the node executes.
Namespaces are isolated: each resolver consumes only its own tokens and leaves every other namespace byte-for-byte intact, so the resolvers can run in sequence over the same string without interfering.
Which namespaces a node resolves depends on its kind — the coverage is not uniform, and the matrix below is the authority.
Namespaces
Section titled “Namespaces”| Namespace | Addresses | Resolved from |
|---|---|---|
steps.<key>.<field> |
An upstream agent step-task’s outcome. <field> is one of branch, summary, pr_url, cost. |
tasks.git_branch and task_outcomes.summary / pr_url / cost_usd |
trigger.<field> |
The payload that started the run. | The run’s trigger_context map |
inputs.<name>[.<slot>] |
An App run’s declared inputs. Text/number/select/checkbox inputs resolve directly; a file input exposes the path, url, bytes, original_filename, mime, size_bytes and sha256_hex slots. |
trigger_context.inputs and the run’s app_uploads rows |
nodes.<key>.<path> |
Any upstream node’s result JSON — including non-agent nodes. | The child run’s result column |
resources.<name>.<field> |
A Workspace resource’s non-secret configuration. | The resources row’s public config |
secrets.<name> |
A credential value. | Resolved late, by the credential layer — never by a notify body, a classify attachment or a connector input |
Substitution modes
Section titled “Substitution modes”The same nodes.* namespace substitutes in one of two modes. The mode is fixed
per consumer — it is not a per-token choice.
| Mode | Applies to | Behaviour |
|---|---|---|
| Prose | notify_* fields, the llm_classify attachment expression |
Substitutes into a string. A non-string value is rendered as its JSON text. A token that cannot be resolved is left verbatim, so a misconfiguration stays visible instead of silently emptying the message. |
| Typed | A connector node’s inputs |
Substitutes into a JSON value tree. A token that cannot be resolved fails the node. |
Typed whole-value substitution
Section titled “Typed whole-value substitution”In typed mode, a string consisting solely of one token is replaced by the referenced JSON value itself, with its type intact:
# `rows` arrives as a JSON array, not as a JSON-encoded string.inputs: rows: "${{ nodes.collect.output.rows }}"A token inside a longer string still interpolates as text, because the result has to remain a string:
# `label` arrives as the string "batch 42 of run alpha".inputs: label: "batch ${{ nodes.collect.output.count }} of run ${{ trigger.name }}"Object keys are never templated — only values, recursively through objects and arrays.
Paths and array indexing
Section titled “Paths and array indexing”nodes.<key>.<path> resolves in two steps:
- An exact field match on the result object wins. This keeps every
pre-existing single-level reference resolving to exactly what it did,
including a field whose value is JSON
null. - Otherwise the dotted path is walked into the result. An object field
descends; a segment that parses as an integer reads an array index. An
object field literally named
0still wins over the array reading.
inputs: first_row: "${{ nodes.query.output.rows.0 }}" city: "${{ nodes.query.output.rows.0.address.city }}"A path that reaches nothing is unresolved — distinct from a field that
genuinely holds null. Depth does not change what a value means:
| Outcome | Prose mode | Typed mode |
|---|---|---|
Path resolves to a value (including null) |
Rendered (null renders as null) |
Substituted |
| Path reaches nothing | Token left verbatim | Node fails |
Segment cap
Section titled “Segment cap”A reference is capped at 32 segments, counted across the whole logical
reference — nodes, the node key, and every path segment. The cap is shared
with gate conditions, so a template and a condition get the same budget for the
same reference. A reference over the cap does not resolve.
Only nodes.* has multi-segment paths:
| Namespace | Path depth |
|---|---|
steps.* |
1 — a fixed set of four fields |
trigger.* |
1 — a flat lookup in the trigger context |
inputs.* |
1, plus a fixed file-slot suffix |
nodes.* |
N — exact field first, then the dotted walk |
Namespace coverage by node kind
Section titled “Namespace coverage by node kind”Measured per node module. The asymmetries are real and load-bearing.
| Node kind / templated slot | steps.* |
trigger.* |
inputs.* |
nodes.* |
resources.* |
|---|---|---|---|---|---|
agent prompt |
yes | yes | yes | no | no |
agent prompt inside a loop body |
yes | yes | no | no | no |
human prompt |
yes | yes | no | no | no |
loop over expression |
yes | yes | no | no | no |
notify_* fields |
yes | yes | no | yes (prose) | yes |
llm_classify attachment expression |
yes | yes | yes | yes (prose) | yes |
code source |
yes | yes | yes | no | no |
connector inputs |
yes | yes | yes | yes (typed) | no |
| every other kind | no | no | no | no | no |
“Every other kind” — gate, end, transform, db_query, db_execute,
http_request, imap_ack, wait_event, app — resolves no ${{ }}
namespace at all.
Two asymmetries are easy to miss:
- An
agentprompt resolvesinputs.*at the top level but not inside aloopbody. - The
llm_classifypayload is not templated at all. Only its attachment expression is.
Size ceilings
Section titled “Size ceilings”Two ceilings exist. They govern different paths and are deliberately not the same number.
| Ceiling | Governs | On breach |
|---|---|---|
| 8 MiB | A connector node’s payload — the resolved inputs it receives and everything it publishes into its run result: the output of a success, the error message of a failure |
The node fails loudly, naming the node key and the actual size. Never truncated. |
| 1 MiB | The inlined-bytes branch of the file-slot namespace, ${{ inputs.<name>.bytes }} |
The bytes slot is skipped; the token stays verbatim so a downstream node can surface it |
The connector ceiling is symmetric on purpose. A connector’s payload is
written to its child run’s result, and that column is exactly what the
nodes.* namespace reads. An uncapped payload would therefore become an
uncapped typed input one node later, and the input guard would fire against
the consuming node for a payload the producing node created. One constant
guards both directions, so the node that produced an oversized payload is the
node that fails.
This covers a failed run too. A failure drops the output but still writes the
guest’s error message into the same result, where ${{ nodes.<key>.error }}
reads it exactly like .output — so the error message is measured against the
same ceiling, and an oversized one fails the node that produced it rather than
travelling on.
The input guard runs after typed resolution, so it measures what the connector would really receive — including whole values inlined by substitution.
Large payloads by reference
Section titled “Large payloads by reference”Typed substitution inlines a whole value, so a single reference can carry a prior node’s entire result. Above the ceiling, the payload does not travel inline at all.
The supported pattern is to pass a name plus a path and let the connector stream the bytes from its own bound resource, over the egress authority that resource already unlocks:
# Instead of inlining the document bytes, travel a reference to them.inputs: documents: - name: "invoice-2026-08.pdf" path: "/exports/2026/08/invoice-2026-08.pdf"Save-time validation
Section titled “Save-time validation”A connector node’s nodes.* references are validated when the workflow is
saved, installed or seeded — not on first run. The whole spec is available
at that moment, so a broken reference is decidable right there.
| Rejected | Reason |
|---|---|
| The referenced node key does not exist in the workflow | A typo would otherwise fail months later, on the first real run |
| The node references its own result | Its result does not exist while it runs |
| The referenced node is not upstream in the DAG | Its result does not exist yet when this node runs |
Upstream means backward-reachable over the graph’s edges plus the loop-body
containment edge: a node nested in a loop legitimately sees everything its
parent frame sees.
The workflow builder’s connector properties panel evaluates the same three rules while you type, and names the upstream node keys that are actually referenceable from the selected node.