Expressions

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

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 does not take one: an expression there is flagged when you save (see What is refused).

The six reference roots

There are exactly six. Anything else is refused (see What is refused).

RootResolves to
${{ vars.<key> }}A workflow variable's value
${{ nodes.<nodeId>.outputs.<port> }}An upstream node's output on that port
${{ trigger.payload }}The payload the trigger produced, or a path into it
${{ in }}The whole value that arrived on this node's input
${{ run.id }}, ${{ run.url }}The run this step belongs to, and its page in the Integration Studio
${{ workflow.id }}, ${{ workflow.name }}That run's workflow

vars, nodes, trigger.payload and in take a dot path after them. run and workflow don't: ${{ run.url.host }} and ${{ workflow.name.de }} are refused.

vars

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

The key must be one you declared in the workflow's variables. An undeclared key is flagged when you save (an error that refuses the save on an active workflow, a warning on an inactive one), and a run fails before the step that uses it — see Variables. A dot path after the key reads into the variable's value, so ${{ vars.erp.baseUrl }} reads baseUrl from an object variable erp.

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 is refused. 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, however the expression goes on after it. A trigger's id doesn't count as a node here: read what a trigger carries through trigger.payload.

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 }}"
}

${{ trigger.payload }} on its own is the whole payload, which is how you hand it to a node in one piece.

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.

At a step where the branches of one split join, in.<step id> holds each branch's result under the id of that branch's last step. For example, the result of a branch that ends at the step with id fetch is in.fetch. A step that did not run has no key.

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.

run and workflow

These name the run a step belongs to, so a ticket, an email or a chat message a workflow sends can link to the run a person will look up:

workflow.json
{
  "subject": "Order import failed in ${{ workflow.name }}",
  "body": "Run ${{ run.id }}: ${{ run.url }}"
}
  • run.id is the run's id. After a resume, a step that runs again gets the id of the resumed run, not the run that failed. Steps that ran before the resume keep the id they had.
  • run.url opens that run in the Integration Studio. It carries no tenant: the studio takes the tenant from the session, so the link works for someone signed in to that tenant.
  • workflow.id is the workflow's id, and workflow.name is its name as it is now, not as it was when the run started.

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 ([0] is refused) 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 path that finds nothing 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 path that finds nothing in the data resolves to nothing. If a field genuinely may be absent, branch on it with If condition and set it with Set Fields.

What is refused, and when

An expression that can't resolve whatever the data is refused, so a typo can't turn into an empty value behind a green run. Four mistakes count:

  1. Anything outside the syntax above: an unknown root such as ${{ secrets.token }}, vars with no key, outputs with no port, an index such as [0], an unknown filter, or an unclosed ${{.
  2. A variable the workflow does not declare.
  3. A node id the workflow does not have, or a port that node does not declare.
  4. A read from a step in a sibling branch, or in.<id> at a join where the id is not one of its branches. Both are refused on every save, active or not.
WhenWhat happens
You save the workflowThe field is marked on the node in the canvas. On an active workflow it is an error and the save is refused; on an inactive one it is a warning, except mistake 4, which is refused either way.
You activate it, start a run by hand, or retry a runRefused with 422 until the field is fixed.
A webhook, schedule or event starts a runThe run fails before that step, with an error naming the step, the field and the expression. It is not retried. This check covers an undeclared variable and an unknown node, not the port: a port mistake is caught when you save, activate, run by hand or retry.

The run-time check covers runs that skip the save-time one, such as a run an agent starts over the workflow MCP server while the workflow is inactive. It only judges the fields the last save judged, so a workflow last saved before the check went live is not checked at run time until you save it again: until then a typo in it still resolves to nothing. A run that was already under way when the check went live, or a resume of one, keeps the old behaviour (as of 2026-10).

What is still quiet. A path that finds nothing in the data resolves to nothing, because only the data knows its keys: de-${{ in.lnag }} becomes de- without complaint, and so does a path under a port, a variable or in that the data does not have. A node whose output ports depend on its settings has none of its ports checked. Fields the save never judges are not checked at run time either: a field hidden by its condition, and a field whose schema is built at run time. (An expression in a field that takes none is flagged when you save.)

Debugging a reference

A path that finds nothing still runs, with a gap in the node's 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 run's Steps — 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?