Skip to main content
Expressions let a flow reference live data at runtime. Anywhere you write {{ ... }} inside a Flow Definition, the platform resolves the enclosed path against the execution’s context — trigger input, definition variables, prior step outputs — just before the task runs.

Where expressions can appear

Context roots

Every expression starts from one of these roots:
var and account are distinct scopes. var is a constant local to a single definition; account is shared configuration your whole account owns and can change on the fly. See Account Variables for when to use which — and why secrets belong in a credential, never in either variable scope.
When a step contains exactly one task, you can skip the task ID: {{greet.output.message}} is shorthand for {{greet.echo_hello.output.message}}.

Dot-path traversal

Paths use dots to walk into nested objects: {{input.contact.address.city}}. Traversal is null-safe — if any segment along the path is missing, the expression resolves to null instead of raising an error. In a mixed string template, a null value renders as an empty string. To supply a fallback, use the default pipe:
The default value is coerced to its natural type: quoted strings stay strings, true/false become booleans, bare numbers become numbers, and null stays null.

Pipes

A pipe reshapes a value on its way into a parameter — trim the whitespace off a name, normalize a phone number, round a cost to two places — without needing a whole step to do it. Write one after a | inside the braces:
Pipes work on one value at a time. To reshape a whole set of records — deduplicate an upload, sort a list, filter it down — use a record transform step instead.

Chaining

Pipes chain left to right, to any depth. Each one receives whatever the one before it produced:
Position matters. The clearest case is default, which only ever sees what reaches it: Put default after the pipe whose failure you want to catch, not before it.

Arguments

Arguments follow the pipe name after a colon, separated by commas:
Each argument is coerced to its natural type, exactly like default’s fallback: quoted strings stay strings, true/false become booleans, bare numbers become numbers, and null stays null.

The full set

Text truncate’s suffix is spent out of the length, not added to it: truncate: 20, '...' returns at most 20 characters including the ellipsis. Truncation is usually there to fit a provider’s column limit, and a suffix that pushed the value past that limit would defeat the pipe you wrote it for. first and last fail on an empty value rather than producing nothing. That is deliberate: a value that quietly became empty would be read further down the chain as missing, and picked up by a default you attached for a different reason entirely. slug fails the same way on a value with no usable characters — an empty identifier is not an identifier. Dates formatDate and dateAdd both read ISO-8601 — a date, or a datetime with or without an offset. Anything else fails rather than being guessed at, so 08/23/2026 is refused instead of being read as one of the two dates it could mean. parseDate is how you read one of those anyway: you supply the format, so there is nothing left to guess. The three compose — {{ input.due | parseDate: '%m/%d/%Y' | dateAdd: 3, 'days' | formatDate: '%b %-d' }}.
toTimezone needs a value that carries an offset, and says so when it does not: 2026-08-23T12:30:00Z and 2026-08-23T12:30:00-04:00 both work, and a bare 2026-08-23T12:30:00 fails with value has no time-zone offset. There is no assumed default zone, because assuming one would mean reading the zone of whichever machine happened to run the step — so the same flow would produce different answers on different runs. Naive timestamps are exactly what turns up when you are converting UTC to local for a quiet-hours or business-hours decision, so this is the one to expect.
dateAdd accepts seconds, minutes, hours, days and weeks, and deliberately not months or years. “One month after 31 January” has no single correct answer — 28 February, 29 February, 2 March and 3 March are all defensible — and approximating a month as 30 days would put a silently wrong date somewhere you would never look for it. A negative amount shifts backwards.
Numbers toNumber returns a whole number when the value is integral, because 3.0 where 3 was meant is the kind of thing a provider rejects. true and false are refused rather than read as 1 and 0, by every one of these.
formatNumber groups in the US/en style — 1,234.50 — and is not locale-aware. Its second argument is a literal prefix, not a currency code: formatNumber: 2, '$' puts a $ in front of the number and nothing more. There is no currency table and no locale lookup, because a pipe runs inside the flow with no access to either, and a half-implemented version of them would be wrong in ways you would not see until a customer read the output. If you need a different grouping style, compose it with replace.
floor rounds toward negative infinity, so floor of -2.1 is -3. abs, floor, ceil and formatNumber all refuse a value that is not a finite number — inf and nan fail rather than being formatted. Encoding Structure jsonDecode is the inverse of jsonEncode, and the reason it exists is the provider that hands you a JSON document inside a string field: {{ step_1.output.body | jsonDecode | get: 'result.id' }} reads into it without a separate transform step. keys and values are sorted by key and line up element for element, so you can pair them.
get is the one pipe that answers a miss with nothing instead of failing. Every other pipe treats a missing value as an error, because attaching a pipe is how you say a value has to be there. get is different on purpose: a field that is absent from a payload is ordinary, not a mistake. That makes {{ input.payload | get: 'a.b' | default: 'x' }} the shape to reach for — get produces nothing, and default decides what nothing means. Without a default, the miss is still caught: the next pipe in the chain refuses it, and a get on its own resolves to null like any bare path. Piping something that is not an object into get does fail, though — that is a mistake about the shape of your data, not a missing field.
urlEncode exempts nothing, / included — its job is to make a value safe as a single path or query segment, and a value containing a slash must not be able to escape that segment. base64Decode refuses text that is not valid base64 rather than quietly discarding the characters that do not belong.
base64Encode is encoding, not encryption. It is a reversible text format that anyone can decode with a single command; it protects nothing, hides nothing, and must never be used as though it did. A value that needs protecting belongs in a credential.
Null safety default substitutes only when the value is missing or an earlier pipe failed. A value that is present — including 0, false and an empty string — passes straight through untouched. Phone numbers All three are the same normalizer the platform uses everywhere else a phone number is stored, so a number normalized in an expression and a number normalized on a data store write always agree. Correct shape is not enough — a well-formed string that is not a real number is refused, by toNational and areaCode as much as by toE164: a number too broken to store is too broken to display. toE164 is what you store; toNational is what a person reads. areaCode returns text, not a number, because an area code is an identifier that happens to be digits.

What fails, and why

A pipe either produces a value or fails the step. There is no third outcome where a value quietly becomes empty and travels on:
  • A value that is present but cannot be transformed fails, with a reason that names the pipe — toNumber on "abc", formatDate on text that is not a date, toE164 on something that is not a number. The failure is immediate and is not retried, because running it again produces exactly the same result.
  • A value that is missing fails the same way entering any pipe other than default, with value is missing. Attaching a pipe is how you declare that a value has to be there — there is no separate “required” marker to remember.
  • A bare path with no pipe still resolves to null, exactly as it always has. Nothing you have already saved changes behaviour.
Failure messages name the pipe and what was wrong; they never quote the value itself, so a phone number or an email address in a bad row does not end up copied into an error log. Inside a record transform that works record by record, a failure is scoped to the record: that row is left out with a reason and the rest of the set continues. One unparseable phone number in fifty thousand does not fail the run.

The escape hatch

| default: is how you say “and if that does not work, use this”. It catches both a missing value and a failed pipe earlier in the chain:
Read the second one as: normalize the number if you can, and if there is no number or it is not a valid one, send an empty string rather than failing the record.

Type preservation

How a value resolves depends on whether the expression stands alone:
  • Whole-value expression — when the entire parameter value is a single {{ ... }}, the resolved value keeps its native type: an object stays an object, an array stays an array, a number stays a number. This is how you pass structured data (like a list of records) from one step to the next without serializing it.
  • Mixed template — when an expression is embedded in surrounding text ("Hello {{input.name}}"), the result is rendered as a string.

Conditions

Exit conditions compare two values with one of six operators: ==   !=   >   <   >=   <=
Either side can be an expression or a literal — quoted strings, bare numbers, true/false, or null. A condition with no operator at all is evaluated for truthiness: it passes if the expression resolves to a truthy value. A task’s skip_if uses this same condition syntax, including the no-operator truthiness rule above — see skipping a task.
The ordering operators (>, <, >=, <=) require both sides to be numeric; if either side can’t be treated as a number, the condition evaluates to false. Equality (==, !=) works on any types.

Validation at creation time

When you create a definition, every string in the body is scanned for malformed expressions. If any are found, the request is rejected with 422 and a list of the problems:
These classes of error are caught at creation time:
  • Unclosed expressions — a {{ with no matching }}.
  • Unknown pipe operators — a pipe name that is not one of those listed above. Every stage of a chain is checked, not just the first, so a misspelling in the last position is caught at save time rather than at run time.
  • The wrong number of arguments for a pipe — round with no places, replace with only one, trim with any at all. The error names the pipe, how many it expects and how many it was given.
  • Unbalanced quotes in an argument list — a ' or " that is never closed.
  • An unknown context root — including row, which is accepted only inside a record transform step that works record by record, and rejected everywhere else. The error lists the roots that were available where you wrote it.
What is not checked is argument meaning: whether a formatDate pattern is the one you intended, or a dateAdd unit is spelled correctly, is decided at run time. A validator that guessed at those would reject definitions that work.
Path validity is still not checked at creation time. A bare path to a field that never exists is accepted and resolves to null at run time, exactly as before. A path followed by a pipe does not: it fails the step with value is missing. That difference is the whole point of attaching one — the pipe is where you say the value has to be there.

Next steps

Flow definitions

Where inputs, variables, steps, and tasks are declared.

Execution models

Immediate, scheduled, windowed, and signaled tasks.

Record transforms

Reshape a whole set of records — deduplicate, filter, sort, map fields.

Catalog

Every action’s full parameter and output reference.