Workflow nodes pass data to each other over wires, and {{ }} templates let you use that data inside any text setting — a prompt, a URL, an email subject, a request body. A template reads what is wired into its step, and can embed a small expression language for reshaping values inline.

Template basics

Text settings in a node's configuration accept {{ }} references:

  • {{name}} — a wired input, by its handle name
  • {{user.profile.email}} — a nested field of a wired input
  • {{trigger.output.email}} — what the run's trigger produced, readable by every step the trigger reaches (trigger.<output>.<path>)
  • {{env.API_HOST}} — a plain workflow environment setting

Spaces inside the braces are fine: {{ name }} and {{name}} resolve identically.

https://api.example.com/users/{{user.id}}

A template never reads another step directly. To use a value an earlier step produced, wire that step's output into this one; the input's name is what the template reads. Every data dependency is then an edge you can see on the canvas.

A setting that is not written text — a choice, a number, a picked record, a secret, a list of secret names, a request's allowed origin — is kept exactly as written and does not take {{ }} values; saving one there is refused. In the settings sidebar, a field that takes them shows a {} button at its right edge.

Two kinds of settings never treat {{ }} as a template: code editors (the Code node's source is delivered verbatim) and the condition fields on control-flow nodes, which take expressions directly — see "Condition fields" below.

How values render

Templates produce text. A referenced value is inserted as:

  • Strings — as-is
  • Numbers and booleans — as text (42, true)
  • Lists of strings — comma-separated (a, b, c)
  • Other lists and objects — JSON
  • Null — an empty string

Inserting variables

You rarely need to type addresses by hand:

  • In the settings sidebar, typing {{ in a text field, or pressing its {} button, opens the value picker. It lists this step's inputs (and trigger), what earlier steps produce, and the workflow's settings. Type to search; several words narrow it ("customer email").
  • Picking an earlier step's value that is not wired in yet adds the edge first: the whole output is wired into an input named after it, and the reference reads the field below it ({{ profile.email }}). An output already wired in is reused.
  • Dragging a field out of an output's value tree on the canvas and dropping it on a text setting does the same at the drop point.
  • A reference shows as a chip named like prose (profile › email). Hovering it shows its source and a sample; a chip that reads a name nothing is wired to, or another step directly, is red.
  • After the workflow has run at least once, the picker shows sample values from previous runs, and a → preview line under the field shows what the setting resolves to. Condition fields have their own autocomplete for input paths and functions.

Expressions

When the content between {{ }} is more than a plain dotted path, it is evaluated as an expression:

{{ upper(name) }}
{{ price * qty }}
{{ default(user.tag, "n/a") }}
{{ items[0].id }}

Operators and literals

  • Logic: &&, ||, !
  • Comparison: ==, !=, >, >=, <, <=
  • Arithmetic: +, -, *, /, % (remainder)
  • Access: .field, [index]
  • Literals: numbers, double-quoted strings ("done"), true, false, null, arrays ([1, 2, 3]), (…) grouping

Semantics to know:

  • Only null and false are falsey — "", 0, and [] are truthy. Test for non-empty with len(x) > 0.
  • A missing field or out-of-range index reads as null, never an error.
  • Arithmetic coerces numeric strings; a null operand or division by zero yields null.
  • == compares by value: 1 == 1.0 is true.
  • Ordering comparisons (>, <, …) work between two numbers or two strings; anything else is false.
  • Strings use double quotes only, with \n, \t, \", and \\ escapes.

Functions

Group Functions
Logic if(cond, a, b) — only the taken branch is evaluated; default(value, fallback) and coalesce(a, b, …) — first argument that is not null or an empty string
String concat, lower, upper, trim, replace, regex_replace, regex_match, mime_match, strip_html, split, join, fold, levenshtein, similar, starts_with, ends_with, pad_start, pad_end, truncate, slugify, to_base64, from_base64, md5, sha256, lines, and safe format(fmt, args…) (%s, %d, %.2f, %% only)
Numbers round, min, max, clamp, to_number, abs, floor, ceil, trunc, mod, pow, sqrt
Collections Lazy per-item forms: map, filter, find, any, all, sort_by, group_by, unique_by, sum, count, min_by, max_by, reduce; plain forms: len, contains, slice, get, pluck, first, last, take, drop, take_last, flatten, reverse, unique, union, difference, intersection, entries, keys, values, pick, omit, merge, zip, range, is_empty, is_nil, type_of
Convert to_string(v), json_parse(s) / from_json(s), to_json(v) / json(v), xml_parse(s)
Time Run-stable now_iso(), now_unix(), now_unix_ms(); to_unix, to_unix_ms, from_unix, from_unix_ms, format_date, parse_date, date_add, signed date_diff(a, b, unit), date_trunc, weekday, day_of_month, date_key, to_date

The now_* functions read a clock that is stamped once per Run, so every node in the same Run sees the same timestamp.

Collection expression arguments are lazy. They run with item, zero-based index, and the outer scope available; reduce also provides acc. group_by returns stable {key, items, count} objects. type_of returns nil, boolean, number, string, list, map, or other.

Date values are ISO-8601 and UTC unless a timezone argument is shown. parse_date uses strftime-style directives (for example %Y%m%d;%H%M%S), converts an IANA-zone wall time to UTC, and date_diff(a, b, unit) returns a - b in whole units. date_key and to_date are UTC YYYY-MM-DD aliases.

Condition fields

The If, Switch, and Loop nodes route on expressions, and their condition fields take the language directly — no braces. The scope is the node's wired inputs at the root:

input.status == "done" && len(input.items) > 0
input.score > env.threshold
  • If routes its input to the true or false output on one condition.
  • Switch evaluates a list of cases (condition + output name); the first match wins. In partition mode it splits a list across the cases with a per-item condition that reads item and index.
  • Loop accepts an exit condition that reads state and iteration.
  • Transform list operations use the same language per list item, with item and index in scope.

Condition fields are fail-closed: a blank or invalid condition fails the node before anything routes, and an evaluation error fails the node. The editor highlights the expression as you type, flags compile errors inline, and shows a live → result preview once sample data from a previous run is available.

Connected inputs, null, and defaults

A node input can be in one of three states, and they mean different things:

  • Not connected. No edge feeds the input. The node uses whatever it is configured with, or its own default.
  • Connected, and the upstream node sent a value. The node receives it — including when that value is null. A null an upstream node chose to send is a value, not silence, and the receiving node decides what it means. Many inputs treat it as invalid and fail the node rather than guess.
  • Connected, but the upstream node did not produce that output. The output name is absent from what actually ran, so the input behaves as if it were not connected and the node's own default applies again.

A gate-skipped branch is a fourth case and is unchanged: a handle fed only by skipped nodes reads as empty so a {{reference}} to it renders blank instead of failing, and if any live branch also feeds that handle, the live value wins.

Connections into a node's run-after / branch header are different: they carry a signal rather than a value. A null arriving there still means "this branch did not fire" and the node is skipped, exactly as before. Only ordinary data connections deliver a null to the node.

This changed on 2026-09-11. Sending null from a node that ran used to be indistinguishable from not producing the output at all, so the receiving node silently fell back to its default. If a workflow relies on emitting null to mean "use the default", change it to omit that output instead — build the object without the key rather than with a null value. That is now the only way to say "I am not sending this".

Omitting works through outputs a node derives from an object, too. When a node pulls an output from a field of another output — a Transform list result, for example — leaving that field out of the object leaves the output unsent, rather than sending null. So unset in a Map pipeline really does mean "do not send this". A field explicitly set to null is still sent as null. A path that cannot be followed at all — into a number, a string, or past the end of a list — is not the same thing and still reads as empty.

When a reference does not resolve

  • A {{path}} reference that resolves to null renders as an empty string.
  • A {{path}} reference that cannot resolve — nothing is wired to that name, or the upstream node was skipped and produced nothing — fails the node before it executes, with an error naming the variable. The literal {{…}} text never reaches an API, prompt, or shell command.
  • Inside an expression, a missing path reads as null — wrap it in default(…) to substitute. An unknown function or an evaluation error fails the node.
  • While you edit, a reference to a name nothing is wired to, or one that addresses another step directly ({{nodes.…}}), is listed under the step's problems. Publish refuses a Draft with such a reference, so it blocks the Release rather than failing later in production.
  • {{env.…}} names are validated against the workflow's declared environment when you save and Publish. Environment values are plain settings; tenant secrets are never copied into this scope.

Literal braces and other template engines

Authored text often carries another engine's template syntax — shell scripts writing GitHub Actions or Helm files, for example. These pass through untouched:

  • ${{ … }} (GitHub Actions) is never treated as a Rollout template.
  • Leading-dot references like {{ .Values.image }} (Helm/Go) pass through — Rollout references start with a letter, digit, or underscore.
  • Any span that does not parse as a path or expression — {{ var | filter }} — passes through verbatim.
  • To force literal braces, escape them: \{\{ name \}\} renders as {{ name }} without being resolved.

Limits

  • An expression can be at most 10,000 bytes, with bounded nesting and complexity.
  • Regex patterns are capped at 1,000 bytes and regex inputs at 100,000 bytes.
  • New string helpers reject inputs above 64 KiB; Levenshtein inputs are capped at 256 characters.
  • range is capped at 10,000 generated items.
  • A {{ }} span cannot cross a line break.

See Visual Workflows for the editor basics, Node Reference for the full node catalog, and HTTP request for templating a request end to end.

Environment redaction

New workflow env keys are plain settings and remain readable in outputs, Task notes and inspection. Select Secret on an Environment row (or set secret_env_keys through workflow upsert) to redact its value. The existing eight-byte minimum applies. If a release comparison marks a key secret on either side, both values are hidden.

Classification freezes with releases, runs and chat previews. Existing stored env keys were conservatively marked secret to preserve unknown credentials. Unmark verified ordinary settings in the Draft and publish; existing runs and redacted history keep their original protection. Marking controls redaction, not encryption: use the tenant-secret store for credentials.