Skip to content

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).

BindingMeaning
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.
processThe current node’s own process result, where applicable.
itemThe 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.
nowCurrent date/time.
isLiveVersionBoolean: 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.

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.

Expressions are typed, so authoring has a fixed order — each step exists because skipping it produces an expression that compiles but resolves wrong:

Terminal window
cai node connect --flow <flowId> --from <src> --to <dst> # 1. wire first — unwired outputs are not in scope
cai expr context <nodeId> --prop <propName> --json # 2. read the exact bindings and types available HERE
cai expr ops --type list --json # 3. check the catalog when unsure
cai expr set <nodeId> --prop <propName> --js '<expr>' # 4. compile + persist + read back atomically
cai run flow <flowId> --inputs values.json # 5. prove it at runtime

A 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.

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 < 5 is true. Text equals coerces both sides to strings; number equals is strict.
  • 0 and false are values, not missing data. Anything that treats them as empty is a bug.
  • defaulting to fills empties — but each type defines “empty” differently (whitespace-only text counts as empty; the number 0 does not).

When a run unexpectedly returns nothing, check resolved configs for empty comparison values before anything else.

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.

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.

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.