Skip to content

Workflow Template Expressions

Workflow Template Expressions

Every node’s config value can reference the workflow’s inputs and the outputs of nodes that ran before it. Brain resolves those references when it executes the node, in apps/brain/src/modules/domain/workflow/engine/template.ts.

There are two dialects. The original one reads values and picks between them. The expression dialect, opted into with {{= ...}}, additionally does arithmetic and rounding. Prefer it for anything new: the original parser is feature-frozen, and everything it does the expression dialect does too. The reasoning is in the ADR.

Every example below is executed as a test in apps/brain/src/modules/domain/workflow/engine/expression.docs.spec.ts, so it cannot drift from the engine.

Reading values

Both dialects read from the same two places, using a node’s alias (the stable name shown in the editor, not its display label):

ReferenceResolves to
{{trigger.prompt}}A workflow input, as declared in Inputs / Outputs.
{{nodes.crop_1.path}}An output field of the node aliased crop_1.
{{= nodes.detect.faces[0].width }}An element of an array output.

A node can only read from nodes that are connected upstream of it, because execution order follows edges. The editor blocks publishing when a reference points at a node that is not an ancestor.

When the whole field is a single placeholder, the value keeps its type — a number stays a number, which is what pixel fields such as image:crop’s width need. When a placeholder sits inside surrounding text, everything is stringified and objects become JSON.

The original dialect

It resolves a path, and offers exactly one choice per placeholder:

FormMeaning
{{trigger.style || 'photo'}}Use 'photo' when the left side is falsy.
{{trigger.style ?? 'photo'}}Use 'photo' only when the left side is null or absent.
{{trigger.enabled ? 'yes' : 'no'}}Branch on truthiness.
{{trigger.kind == 'video' ? 30 : 1}}Branch on equality (== and !=).

That is the whole grammar. There is no arithmetic, no parentheses, and no chaining: a second || in one placeholder, or a comparison other than equality, does not parse. Nothing is reported when that happens — the placeholder resolves to an empty value and the node runs with it. This silent failure is the main reason to write new fields in the expression dialect, and why the editor now flags expression syntax that is missing its = prefix.

An unresolved path behaves the same way: {{nodes.typo.path}} yields an empty value rather than an error.

The expression dialect

Prefix the body with = and the rest is evaluated as a JEXL expression: {{= round(nodes.detect.faces[0].width * 1.2) }}. JEXL parses the expression into a tree and walks it; there is no JavaScript eval and no access to anything but the values above.

Available: + - * / %, comparisons (== != > >= < <=), && || !, ??, parentheses, indexing, a ternary, and string joining with +.

Six helpers are registered, each usable as a function or as a transform, so round(x, 2) and x|round(2) are the same call:

HelperDoes
round(value, digits?)Nearest integer, or that many decimals.
floor(value, digits?)Rounds down.
ceil(value, digits?)Rounds up.
length(value)Items in an array, keys in an object, characters in a string; 0 for an absent value.
number(value)Parses a numeric string, so + adds instead of joining text.
truthy(value)The original dialect’s truthiness, where an empty array or object counts as false.

Unlike the original dialect, a broken expression fails the node with the parser’s message instead of resolving to nothing. So does an expression that produces NaN — usually a reference that did not resolve, or text where a number was expected.

Worked examples

These assume a graph with image:face_detect aliased detect (it outputs faces[], each with x, y, width, height), a node aliased probe that reports an image’s width and height, and one aliased price that reports an amount.

Halve a dimension, for a image:resize width:

{{= round(nodes.probe.width / 2) }}

Crop around a detected face with 10% padding, filling image:crop’s left and width:

{{= floor(nodes.detect.faces[0].x - nodes.detect.faces[0].width * 0.1) }}
{{= round(nodes.detect.faces[0].width * 1.2) }}

Padding can push an offset below zero on a face near the edge, and the crop node rejects negative pixels. There is no clamp helper, so compare:

{{= (nodes.detect.faces[0].y - 200) > 0 ? nodes.detect.faces[0].y - 200 : 0 }}

Money to two decimals:

{{= round(nodes.price.amount * 1.23, 2) }}

Fall back when a collection came back empty. length is the check to use, and the parentheses are required (see the gotchas below):

{{= (nodes.detect.faces|length) ? nodes.detect.faces[0].width : 512 }}

Or keep the original dialect’s exact truthiness while migrating a field:

{{= (truthy(nodes.detect.faces)) ? 'found' : 'none' }}

Fall back on a missing input, and join text:

{{= trigger.style ?? 'photo' }}
{{= trigger.prompt + ', cinematic' }}

Gotchas worth knowing before you write one

The pipe binds tighter than arithmetic. nodes.probe.width * 1.5|round rounds 1.5 and then multiplies, so a 1024px width yields 2048 instead of 1536. It is valid and silently means something else. Use the call form round(nodes.probe.width * 1.5), or parenthesise: (nodes.probe.width * 1.5)|round.

A ternary cannot directly follow a call or a transform. truthy(x) ? a : b and items|length ? a : b are both syntax errors. Wrap the condition: (truthy(x)) ? a : b. Brain and the editor both append this fix to the parser’s otherwise cryptic “Token ? (question) unexpected”.

array.length does not resolve. JEXL reads a property off an array by taking it from the first element, so faces.length is absent rather than a count. Use faces|length.

+ joins numeric strings. A value that arrived as "41" makes x + 1 produce "411". Wrap it: number(x) + 1.

Every array and object is truthy here. In the original dialect an empty array is falsy, so {{items || 'none'}} falls back on it; {{= items ?? 'none' }} and {{= items || 'none' }} do not. Use length or truthy. The editor warns when a fallback’s left side is a declared array or object.

A placeholder body cannot contain }. Object literals are therefore unavailable inside a placeholder.

What the editor does for you

In flank’s node config panel:

  • Typing {{ or {{= opens completion for trigger inputs, node aliases and their output fields, with the declared type shown as a badge. After a | it offers transforms, filtered to those that suit the value’s type.
  • Ctrl+Space opens the full list at the caret, and inserts a placeholder first when the caret is outside one. Cmd+Space is Spotlight on macOS and never reaches the page.
  • Problems appear under the field shortly after you stop typing. Red blocks publishing; amber is advice.

Publishing is blocked when a field mixes both dialects, when a placeholder carries expression syntax without its = prefix, when an expression does not parse, or when it calls a helper that does not exist. The rules live in apps/flank/app/lib/template-validation.ts and run against every node’s config, including fields the panel edits as raw JSON.

Migrating a field

Rewrite the whole field at once, because one field may not mix dialects — the two disagree on truthiness, so a half-migrated field cannot be reasoned about mechanically. The mapping is direct:

OriginalExpression dialect
{{trigger.a}}{{= trigger.a }}
{{trigger.a ?? 'b'}}{{= trigger.a ?? 'b' }}
{{trigger.a || 'b'}}{{= (truthy(trigger.a)) ? trigger.a : 'b' }}
{{trigger.a == 'x' ? 1 : 2}}{{= trigger.a == 'x' ? 1 : 2 }}

|| is the only form that needs care: keep truthy when the value can be an empty array or object, and use plain || when it cannot.