Expressions

The ${{ }} template syntax — its four reference roots, dot paths and array auto-mapping, the two filters, and how a resolved value is typed.

Expressions are how a node's configuration reads data it did not have when you typed it: an upstream node's output, the trigger payload, a workflow variable, or whatever arrived on this node's own input.

An expression is delimited by ${{ … }} — a dollar sign and double braces:

workflow.json
{
  "id": "push-orders",
  "nodeSlug": "revenexx:http-request",
  "nodeVersion": "1.1.0",
  "config": {
    "method": "POST",
    "url": "${{ vars.targetBaseUrl }}/orders",
    "body": "${{ nodes.transform.outputs.out }}"
  }
}

Expressions are resolved per node, immediately before it executes, and only in fields the node declares as expression-capable. A field that is not expression-capable takes your text literally, braces and all.

The four reference roots

There are exactly four. Nothing else resolves.

RootResolves to
${{ vars.<key> }}A workflow variable's value
${{ nodes.<nodeId>.outputs.<port> }}An upstream node's output on that port
${{ trigger.payload.<path> }}A path into the payload the trigger produced
${{ in }}The whole value that arrived on this node's input

vars

workflow.json
{ "path": "${{ vars.inboxPath }}" }

The key must be one you declared in the workflow's variables. An undeclared key resolves to nothing — see Variables.

nodes.<nodeId>.outputs.<port>

Nodes are referenced by the id you gave the node, not by position. Reordering the canvas cannot break a reference, and a reference reads as what it means:

workflow.json
{
  "url": "${{ nodes.list-files.outputs.out }}",
  "status": "${{ nodes.fetch.outputs.response.status }}",
  "orderTotal": "${{ nodes.fetch.outputs.response.body.total }}"
}

The port name is required — ${{ nodes.fetch.outputs }} on its own does not resolve. Everything after the port is a dot path into that port's value.

Both halves are checked when the workflow is saved: the node id has to exist in the graph, and the port has to be one the upstream node actually declares.

trigger.payload.<path>

What the payload contains depends on the trigger — see Triggers.

workflow.json
{
  "orderId": "${{ trigger.payload.body.order_id }}",
  "topic": "${{ trigger.payload.topic }}"
}

A path segment is required. ${{ trigger.payload }} on its own does not resolve.

in

in is the value on this node's input port — what the node immediately upstream handed it. Inside an iterator's item branch, that is the current element.

workflow.json
{
  "body": "${{ in }}",
  "sku": "${{ in.supplier_pid }}"
}

Use in for "whatever just arrived" and nodes.<id>.outputs.<port> when you need to reach further back than one step.

Dot paths map over arrays

A dot path that crosses an array is applied to every element, and you get an array back. There is no index syntax and no loop needed to pull one field out of a list:

workflow.json
{
  "skus": "${{ in.articles.supplier_pid }}"
}

If in.articles is a list of fifty objects, skus is a list of fifty ids in the same order.

Two filters

A filter is appended with a pipe. There are two of them, and that is the whole set.

join:"<separator>" turns an array into a string. \n and \t in the separator are honoured:

workflow.json
{
  "text": "${{ in.reviews.comment | join:\"\\n- \" }}"
}

length counts: elements of an array, characters of a string, keys of an object.

workflow.json
{
  "subject": "${{ in.articles | length }} articles imported"
}

How the result is typed

A whole-string expression keeps its type. If the field's entire value is one expression, you get the underlying value — a number stays a number, an object stays an object, an array stays an array. This is how you pass a whole payload from one node to the next.

Anything else becomes a string. Two expressions in one field, or an expression with literal text around it, is string interpolation: numbers and booleans are stringified, an array is joined with , , an object is rendered as JSON, and a reference that does not resolve contributes an empty string.

workflow.json
{
  "wholeValue": "${{ nodes.transform.outputs.out }}",
  "interpolated": "Order ${{ trigger.payload.body.id }} for ${{ in.customer.name }}"
}

Expressions are resolved recursively through nested objects and arrays in a node's config, so an expression inside an object field works the same way.

What expressions do not do

Being explicit about the boundaries, because most of what people try first is one of these:

  • No arithmetic and no function library. An expression reads a value; it does not compute one. Use Set Fields, Array Tools or Date Time for that — see Node catalog.
  • No comparisons. Conditions are not expressions. If condition, Switch and Filter take a field, an operator and a value as separate configuration.
  • No secrets.* root. A secret is not reachable from an expression. A node that needs one declares a secret-reference field and you pick the key there; the value is resolved inside the node at execution time and never enters the workflow document or the run history.
  • No env.* root. There are no environment variables. Anything that differs between installations belongs in a workflow variable.
  • No positional step references. There is no steps.0.outputs. Reference nodes by id.
  • No bare $. ${{ $ }} and ${{ $now }} are not syntax. Use ${{ in }} for the input, and the Date Time node for the current time.
  • No default or fallback filter. A missing value resolves to nothing. If a field genuinely may be absent, branch on it with If condition and set it with Set Fields.

Debugging a reference

A reference that does not resolve is not an error — it resolves to nothing, and the node runs with a gap in its config. That is quiet, so build expressions the way the editor is designed for:

  1. In the node's inspector, open the input panel and pick the value from the upstream node's output, the trigger payload or a variable, rather than typing the path. The matching expression is written into the field for you.
  2. Run the single node against a sample input and read its output before wiring the next one.
  3. Then run the whole workflow once by hand and read the timeline — each step's real input and output, which is where a path that looked right turns out to be one level off.

Where to go next

  • Node catalog — which fields accept expressions, and what each node's ports are called.
  • Triggers — what trigger.payload holds for each trigger type.
  • Credentials — the reference fields that replace a secrets.* expression.
Was this page helpful?