{{ ... }} 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.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:
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:
Chaining
Pipes chain left to right, to any depth. Each one receives whatever the one before it produced: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:default’s fallback: quoted strings stay strings, true/false become booleans, bare numbers become numbers, and null stays null.
The full set
Texttruncate’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' }}.
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.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.
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 —
toNumberon"abc",formatDateon text that is not a date,toE164on 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, withvalue 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.
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:
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:== != > < >= <=
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 with422 and a list of the problems:
- 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 —
roundwith no places,replacewith only one,trimwith 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.
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.
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.