Expressions
Expressions compute every dynamic value in a workflow — node props, conditions, filter constraints, template text. They are written in a restricted JavaScript-like language that compiles to a typed AST. The app’s visual expression builder and the JS surface are two views of the same stored expression; cai expr decompile renders any stored expression back to JS.
It is not general JavaScript: a program is optional const declarations followed by one final expression. No loops, assignments, new, regular expressions, optional chaining, or arbitrary methods — the operation catalog defines what exists, and cai expr ops --type <type> is always the authoritative list (reference snapshot).
What you can reference
Section titled “What you can reference”| Binding | Meaning |
|---|---|
flow.<name> | A flow input. |
inputs.<name> | A wired upstream node’s output. Trigger payloads arrive this way too — there is no separate trigger root. |
process | The current node’s own process result, where applicable. |
item | The injected current item — inside a per-item flow run or a list operation’s inner expression. |
workflowData("custom.x") | The records of an active data collection, as a list. The only way to query the data store. |
now | Current date/time. |
isLiveVersion | Boolean: which rail this run is on — the key to safe-audience branching. |
Text interpolation is a JS template literal:
`Hello ${flow.customer_name}, your order ${inputs.order.id} shipped.`Non-text values stringify automatically in templates — except plain objects, which throw. Extract the field, don’t interpolate the object.
Bindings are frozen
Section titled “Bindings are frozen”A flow input’s display label and its expression binding are different things. The server derives the binding from the label once, then freezes it — renaming the label later does not rename the binding or rewrite any expression. Never derive a binding from a display name; read the real one from cai expr context.
The write loop
Section titled “The write loop”Expressions are typed, so authoring has a fixed order — each step exists because skipping it produces an expression that compiles but resolves wrong:
cai node connect --flow <flowId> --from <src> --to <dst> # 1. wire first — unwired outputs are not in scopecai expr context <nodeId> --prop <propName> --json # 2. read the exact bindings and types available HEREcai expr ops --type list --json # 3. check the catalog when unsurecai expr set <nodeId> --prop <propName> --js '<expr>' # 4. compile + persist + read back atomicallycai run flow <flowId> --inputs values.json # 5. prove it at runtimeA compile success proves syntax and types — not that you picked the right source. Runtime proof is reading resolved configs: a null or empty resolved value on a green node is an expression failure (cai exec ports). And a node run in isolation doesn’t execute upstream nodes, so only a flow run proves an inputs.*-derived value.
If a field you need isn’t in scope, the cause is almost always an unresolved upstream output type — resolve it first (type system), don’t guess paths.
Empty values: the semantics that bite
Section titled “Empty values: the semantics that bite”Expressions do not throw on missing data — they null-propagate, and the run stays green. Know these rules:
- An empty input evaluates to its type’s empty form:
""for text, null for objects. An optional text input left blank arrives as"", not null. - Filters fail closed. A filter constraint compared against an empty value matches no records — deliberately (matching everything by accident would be worse). If blank should mean “skip this filter”, that’s an explicit per-constraint opt-in, never the default. Classic symptom: search returns zero results only when the optional filter is blank — and a downstream “create if missing” step then makes duplicates.
- Comparison strictness varies by type. Date comparisons fail closed on empty values. Number comparisons are raw JavaScript —
null < 5is true. Textequalscoerces both sides to strings; numberequalsis strict. 0andfalseare values, not missing data. Anything that treats them as empty is a bug.defaulting tofills empties — but each type defines “empty” differently (whitespace-only text counts as empty; the number0does not).
When a run unexpectedly returns nothing, check resolved configs for empty comparison values before anything else.
Off-by-one worth memorizing
Section titled “Off-by-one worth memorizing”list until (n) is exclusive — it keeps items 1 through n−1. list from (n) is inclusive. Getting “the first 10” is list until (11) or better, take-style patterns from the ops catalog.
Where expressions attach
Section titled “Where expressions attach”Any prop in expression mode, plus: node run-conditions (Only when), If/Else and Router branch conditions (cai branch add/update), and list operations’ inner expressions (filter constraints, per-field mapping). Text-like props are natively dynamic — templates go straight into the value.
Example prompts
Section titled “Example prompts”This filter returns zero records only when I leave the country field blank. Find the empty comparison value and make blank mean "skip this constraint" explicitly.Write the Only when condition so this node runs only on the live rail, and route dev runs to my own email instead.