Workflow designer

Workflow expressions and data pinning

Expressions let any node read the output of any earlier node, index into lists and compute values, without chaining Code nodes. Data pinning freezes a node's output so you can build and test the steps after it without running the steps before it again.

Not released yet. Expressions are built but have not been deployed, so the Expression toggle does not appear in the designer today. For data pinning, the designer controls are built but the server side (saving pins and skipping pinned nodes in draft runs) is not, so pinning does not work yet. This page describes the intended behavior once both are released.

Syntax

An expression sits inside double curly braces in a node field. Everything outside the braces is literal text, and one field can hold several expressions.

Order for {{ $json.customer.name }}: total {{ sum($json.items, i => i.price * i.qty) }}
  • The old placeholders still work. {{input}} is the node's raw input and {{fieldName}} is a top-level field of the input when it is a JSON object. Use them for simple cases and reach for the richer syntax only when you need it. A bare name that matches nothing stays in the text as written.
  • Results become text. A number or boolean is written as itself, an object or list as compact JSON, and null or a missing value as empty text. Inside a field that is exactly one expression and expects structured data, the value keeps its type.
  • Missing paths are empty, not errors. $json.a.b.c is empty when a has no b. Use ?. and ?? to say so explicitly.
  • Values are never evaluated twice. If incoming data, such as a webhook body, itself contains {{ ... }}, that text is inserted as-is and never run.
  • Secrets are not expressions. Keep API keys in the vault reference form, {{secret.NAME}}; $secrets is not available inside expressions.

Literals are numbers, "text" or 'text', true, false, null, lists [1, 2] and objects {key: value}.

Data roots

Names starting with $ are the places an expression reads from.

RootWhat it is
$jsonThis node's input, parsed when it is JSON, otherwise the raw text.
$inputThis node's input as raw text.
$node["Label"]The output of an earlier node, addressed by its label (parsed when it is JSON). $node["Label"].output is the raw text, .status its status, .summary its summary. $node.id("n7") addresses a node by id. The node must be upstream of the one using it.
$triggerThe payload that started the run: webhook body, channel message, schedule tick or event.
$runid, startedAt, mode (production, draft or test) and requestedBy for the current run.
$workflowid and name of the workflow. version exists but is empty for now.
$varsWorkflow variables, read-only. There is no editor for them yet, so this is usually empty.
$item, $index, $itemsInside a loop or fan-out body: the current element, its position, and the full list.
$nowThe instant the expression is evaluated, as an ISO-8601 UTC string.

Renaming a node updates the $node["Label"] references to it in the same edit. If two nodes share a label, address the one you mean by id. Referencing a node that is not upstream is flagged in the lint panel, because it could never have run.

Operators

Listed from lowest to highest precedence.

OperatorMeaning
a ? b : cTernary: b when a is truthy, otherwise c. Lowest precedence.
a ?? bb when a is null or missing.
a || ba when it is truthy, otherwise b.
a && bBoth must be truthy.
== !=Equality. Strict on type, except that a number equals numeric text (1 == '1' is true).
< > <= >=Comparison.
+ -Add and subtract. + joins text when either side is text ('a' + 1 is a1).
* / %Multiply, divide, remainder.
! -Not, and negate. Highest precedence.
a.b a?.b a["k"] a[0] a[-1]Property access, safe access, key lookup, index, and last item (-1).
x.fn(args)Method form: sugar for fn(x, args), so items.filter(i => i.qty > 0) equals filter(items, i => i.qty > 0).
i => exprOne-parameter arrow function. Only accepted as an argument to a built-in.

Functions

63 built-in functions. Call them as fn(x, args) or x.fn(args). The list below is generated from the engine's own function registry, and every example was evaluated by the engine to produce the result shown. Functions that take a function accept an arrow function such as i => i.price.

String (16)

FunctionWhat it doesExampleResult
lower(text)Converts text to lower case.lower('HeLLo')"hello"
upper(text)Converts text to upper case.upper('abc')"ABC"
trim(text)Removes leading and trailing whitespace.trim(' hi ')"hi"
split(text, separator)Splits text into an array on a literal separator. An empty separator splits into characters.split('a,b,c', ',')["a","b","c"]
join(array, separator = ",")Joins array items into one string; null items become empty text.join(['a','b'], '-')"a-b"
replace(text, search, replacement)Replaces every occurrence of a literal substring.replace('a-b-c', '-', '+')"a+b+c"
regexReplace(text, pattern, replacement, flags = "")Replaces every regex match. The replacement may reference numbered capture groups; flags are i, m, s. Patterns are length-capped and time-limited.regexReplace('a1b22', '[0-9]+', '#')"a#b#"
substring(text, start, end = length)Returns the part of text between two zero-based positions (clamped to the text).substring('abcdef', 1, 4)"bcd"
contains(textOrArrayOrObject, value)True if the text contains the substring, the array contains the value, or the object has the key.contains('hello', 'ell')true
startsWith(text, prefix)True if the text starts with the prefix.startsWith('hello', 'he')true
endsWith(text, suffix)True if the text ends with the suffix.endsWith('hello', 'lo')true
length(textOrArrayOrObject)Number of characters, array items or object keys; 0 for null.length([1, 2, 3])3
padStart(text, length, pad = " ")Left-pads text to the given length by repeating the pad text.padStart('7', 3, '0')"007"
urlEncode(text)Percent-encodes text for use in a URL component.urlEncode('a b&c')"a%20b%26c"
base64(text)Base64-encodes the UTF-8 bytes of the text.base64('hi')"aGk="
base64Decode(text)Decodes base64 text to a UTF-8 string.base64Decode('aGk=')"hi"

Number (10)

FunctionWhat it doesExampleResult
round(number, digits = 0)Rounds to a number of decimal places (halves round away from zero).round(2.567, 2)2.57
floor(number)Rounds down to the nearest integer.floor(2.9)2
ceil(number)Rounds up to the nearest integer.ceil(2.1)3
abs(number)Absolute value.abs(-5)5
min(a, b, ...) or min(array)Smallest of the given numbers or of one array.min(3, 1, 2)1
max(a, b, ...) or max(array)Largest of the given numbers or of one array.max(3, 1, 2)3
sum(array, fn = x => x)Adds up an array of numbers (null items are skipped); an optional function picks the number from each item.sum([1, 2, 3])6
avg(array, fn = x => x)Average of an array of numbers (null items are skipped); null for an empty array.avg([1, 2, 3])2
toNumber(value)Converts a number or numeric text to a number; null if it is not numeric.toNumber('42')42
toFixed(number, digits)Formats a number with a fixed number of decimals, as text.toFixed(3.14159, 2)"3.14"

Array (14)

FunctionWhat it doesExampleResult
map(array, fn)Transforms every item with a function.map([1, 2, 3], x => x * 2)[2,4,6]
filter(array, fn)Keeps the items for which the function is truthy.filter([1, 2, 3, 4], x => x > 2)[3,4]
find(array, fn)First item for which the function is truthy, or null.find([1, 2, 3], x => x > 1)2
some(array, fn)True if the function is truthy for at least one item.some([1, 2, 3], x => x > 2)true
every(array, fn)True if the function is truthy for every item (true for an empty array).every([1, 2, 3], x => x > 0)true
first(array)First item, or null for an empty array.first([7, 8])7
last(array)Last item, or null for an empty array.last([7, 8])8
slice(arrayOrText, start, end = length)Part of an array or text between two positions; negative positions count from the end.slice([1, 2, 3, 4], 1, 3)[2,3]
sort(array, keyFn or "asc"/"desc", order = "asc")Sorts a copy of the array (stable). Give a function to sort by a derived key; nulls sort last.sort([3, 1, 2])[1,2,3]
unique(array)Removes duplicate items, keeping the first of each.unique([1, 2, 2, 3])[1,2,3]
flatten(array, depth = 1)Flattens nested arrays by the given depth.flatten([[1], [2, [3]]])[1,2,[3]]
reverse(arrayOrText)Reverses an array or text (returns a copy).reverse([1, 2, 3])[3,2,1]
count(array, fn)Number of items, or of items for which the function is truthy.count([1, 2, 3, 4], x => x % 2 == 0)2
pluck(array, path)Reads a property path (like "a.b[0]") from every item.pluck([{a: 1}, {a: 2}], 'a')[1,2]

Object (7)

FunctionWhat it doesExampleResult
keys(object)The object's keys as an array.keys({a: 1, b: 2})["a","b"]
values(object)The object's values as an array.values({a: 1, b: 2})[1,2]
entries(object)The object as an array of [key, value] pairs.entries({a: 1})[["a",1]]
merge(a, b, ...)Shallow-merges objects left to right; later keys win.merge({a: 1}, {b: 2}){"a":1,"b":2}
pick(object, key, ...)A copy of the object with only the listed keys (keys may also be one array).pick({a: 1, b: 2}, 'a'){"a":1}
omit(object, key, ...)A copy of the object without the listed keys (keys may also be one array).omit({a: 1, b: 2}, 'a'){"b":2}
get(value, path, default = null)Reads a property path like "a.b[0].c" (negative indexes count from the end); returns the default when missing.get({a: [{b: 5}]}, 'a[0].b', 0)5

Date (7)

FunctionWhat it doesExampleResult
now()The current instant as an ISO-8601 UTC string (same value as $now).now()"2026-09-30T10:00:00Z"shown for a fixed test instant
formatDate(date, pattern = ISO, zone = "UTC")Formats an ISO-8601 date or epoch-millis number with a java.time pattern (yyyy-MM-dd HH:mm) in an IANA zone.formatDate('2026-09-30T10:00:00Z', 'yyyy-MM-dd')"2026-09-30"
parseDate(text, pattern = ISO, zone = "UTC")Parses text (ISO-8601, or a java.time pattern) into an ISO-8601 UTC string.parseDate('30/09/2026', 'dd/MM/yyyy')"2026-09-30T00:00:00Z"
addDays(date, days, zone = "UTC")Adds (or subtracts, if negative) whole days, calendar-aware in the given zone.addDays('2026-09-30T00:00:00Z', 2)"2026-10-02T00:00:00Z"
addHours(date, hours)Adds (or subtracts) whole hours.addHours('2026-09-30T00:00:00Z', 5)"2026-09-30T05:00:00Z"
diffDays(from, to, zone = "UTC")Whole days from the first date to the second (negative if it is earlier).diffDays('2026-09-01T00:00:00Z', '2026-09-11T00:00:00Z')10
toEpoch(date)Milliseconds since 1970-01-01T00:00:00Z.toEpoch('1970-01-01T00:00:01Z')1000

Type (7)

FunctionWhat it doesExampleResult
toString(value)Converts a value to text (null becomes empty text, objects and arrays become JSON).toString(12)"12"
toBoolean(value)Converts to true/false: "true"/"yes"/"1" are true, "false"/"no"/"0"/empty are false; numbers are true unless 0.toBoolean('true')true
parseJson(text)Parses JSON text into a value; an error if it is not valid JSON.parseJson('{"a":1}'){"a":1}
toJson(value)Serialises a value to compact JSON text.toJson({a: 1})"{\"a\":1}"
typeOf(value)One of null, boolean, number, string, array, object.typeOf([1])"array"
isEmpty(value)True for null, empty text, empty arrays and empty objects.isEmpty('')true
isNumber(value)True for numbers and numeric text.isNumber('12')true

Logic (2)

FunctionWhat it doesExampleResult
if(condition, then, else)Evaluates only the branch that is chosen.if(1 > 0, 'yes', 'no')"yes"
coalesce(a, b, ...)The first argument that is not null; later ones are not evaluated.coalesce(null, 'fallback')"fallback"

Where expressions work

Only these node fields are evaluated. Any other field is passed through untouched, so braces typed into it stay as text.

  • Agent: the query in inputMapping
  • Code: the template operation's template
  • HTTP: url, body, header values and query values
  • Tool: the arguments JSON
  • Output: emailTo, telegramChatId, teamsConversationId, whatsappTo, driveFolderId, driveFileName, fileName
  • Bulk email: subject and body (rendered once per recipient) and replyTo
  • CRM lead: action, ids and contact fields, stage, score, value, tags, owner, notes and activity fields
  • Device control: the payload
  • Condition: the expression mode (a bare expression, written without braces)

In the designer, each of these fields has a Fixed / Expression toggle, autocomplete for the roots, upstream node labels and function names, and a live preview of the result when sample data is available. A loop or fan-out body can use $item, $index and $items. A sub-workflow receives its input as $trigger and cannot read the parent's node outputs.

Data pinning

A pin is a saved output attached to a node on the draft workflow. When you run the draft or test a step, a pinned node does not run: it returns its pinned output, the trace marks it PINNED, and the run carries on to the next node. You keep real data in front of the nodes you are editing without paying again for the LLM call, the API request or the email upstream.

How to use it

  1. Run the step once with Test step, or open a past run. Real data comes back.
  2. Choose Pin output on that result, or use the pin icon on the node and paste the JSON you want it to return. Invalid JSON is rejected before it is saved; text that is not JSON is saved as plain text.
  3. The node shows a pinned badge and a banner in its settings with the pin's source and age, and options to view, edit or unpin.
  4. Build the nodes after it. Their expression previews use the pinned data, and draft runs and step tests skip the pinned node. The run summary states how many nodes were pinned, so a green draft run is never mistaken for a full end-to-end run.
  5. Unpin when you are done, or leave the pin in place; it never affects live runs.

Rules worth knowing

  • Live runs ignore pins. Webhooks, schedules, channel messages, events, API runs and share links always run every node for real. The engine decides this from the run mode, not from anything the browser sends.
  • Side-effect nodes can be pinned. Pinning the email, CRM or HTTP node lets you test what comes after it without sending anything again.
  • Trigger samples are the best pin. A webhook or channel trigger cannot be tested any other way, so pin a received event and build against it.
  • Redacted or cut-off data. Run records replace secret-looking values with [redacted] and cut long outputs. Pinning from such a record asks you to confirm, because the pin will not behave like the real data.
  • Size caps. 256,000 characters per pin, 30 pins and 2,000,000 characters in total per workflow.
  • Access. Only people who can edit the workflow can view or change pinned data, since it can contain real customer data. Someone who can only run the workflow can run a draft that uses the pins but cannot read them.
  • Pins stay with the draft. They are not part of saved versions, shared links or marketplace templates, and duplicating a workflow does not copy them.

Worked examples

Each result below is what the engine returned for the sample data shown.

1. Reach back to an earlier step

A node labelled Fetch Order returned {"customer":{"name":"Asha","tier":"gold"},"items":[{"name":"Pen","price":120,"qty":2},{"name":"Book","price":450,"qty":1}]}

Expression

{{ round($node["Fetch Order"].items[0].price * 1.18, 2) }}

Result

141.6

Works from any node downstream of Fetch Order, not only the one right after it. Price of the first item plus 18 percent, rounded to two decimals.

2. Total an array

Same Fetch Order output.

Expression

{{ sum($node["Fetch Order"].items, i => i.price * i.qty) }}

Result

690

sum takes an optional function that picks the number from each item: 120 x 2 + 450 x 1.

3. Fall back to a default

This node's input is {"customer":{"name":"Asha","tier":"gold"},"items":[{"name":"Pen","price":120,"qty":2},{"name":"Book","price":450,"qty":1}]}

Expression

Hello {{ $json.customer.nickname ?? $json.customer.name }}

Result

Hello Asha

nickname is absent, so ?? picks the name. A missing path is empty, not an error. Text outside the braces stays as written.

4. Route on a condition

A Condition node in expression mode, same input as example 3.

Expression

$json.customer.tier == "gold" && sum($json.items, i => i.price * i.qty) > 500

Result

true

Condition expressions are bare, with no {{ }}. true sends the run down the true edge.

5. Filter, pick and join

Same input as example 3.

Expression

{{ $json.items.filter(i => i.qty > 1).pluck("name").join(", ") }}

Result

Pen

Method chaining reads left to right. With more matching items you would get a comma-separated list.

6. Use the trigger payload and dates

A webhook delivered {"orderedAt":"2026-09-30T10:00:00Z"}.

Expression

{{ formatDate(addDays($trigger.orderedAt, 7), "yyyy-MM-dd") }}

Result

2026-10-07

Seven days after the order, formatted as a date. Dates are ISO-8601 in UTC unless you pass an IANA zone such as Asia/Kolkata.

Errors and limits

Syntax mistakes are caught as you type and in the lint panel, with the position and, for a misspelled function, a suggestion. A runtime error marks that node as failed with a message naming the expression and the field, and follows the node's normal retry and skip handling. Expressions only transform data: they cannot call code, read files or reach the network.

LimitValue
Expression length2,000 characters
Syntax tree size500 nodes
Evaluation steps20,000 (every node visit and every array-callback iteration)
Time per field50 ms
Result size256 KB
Regular expressionsPattern length capped at 200 characters and time-limited

Going over a limit is a normal expression error on that node, never a hang. If an expression reads an upstream output that was too large to keep in full, it fails with a message asking you to shrink it with a Transform node rather than silently reading part of it.

Related