Skip to content
Select themeSelect language

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.

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

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.

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.

nodes.<key>.<path> resolves in two steps:

  1. 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.
  2. 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 0 still 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

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

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 agent prompt resolves inputs.* at the top level but not inside a loop body.
  • The llm_classify payload is not templated at all. Only its attachment expression is.

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.

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"

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.