Skip to content

Write Workflow Expressions

How do you pass the right value to the next step? Use an expression to read a flow input, a wired node output, or stored workflow data. Then apply operations for its type. For Daily invoice digest, filter the invoice queue and turn the matching IDs into one message. The assistant syntax supports const declarations followed by a final expression. This restricted JavaScript-like syntax does not support arbitrary JavaScript.

In the app: open Workflows → Daily invoice digest in development. Connect the source node to the destination, select the destination text field, and click Insert dynamic data. Choose the source and its operations in the expression panel.

With an assistant: list the workflows to find the digest’s ID.

Terminal window
cai workflow list --limit 100 --json

Note the digest’s data.items[].id as <workflowId>. If it is absent and data.hasMore is true, find its ID in the app. This page pins the workflow with cai use. Subsequent CLI commands omit --workflow.

Pin the workflow to list its flows.

Terminal window
cai use "<workflowId>" --json
cai flow list --json

Note the selected data.items[].id as <flowId>. Inspect that flow to find its source and destination nodes.

Terminal window
cai flow get "<flowId>" --json

From data.nodes[].id, note the source as <sourceNodeId> and destination as <nodeId>. List the destination’s properties to find the text property’s name.

Terminal window
cai node props "<nodeId>" --json

Use the text property’s data.props[].name as <name>.

MCP uses node_prop_list with nodeId and a string workflowId.

If the connection is absent, connect the nodes. Inspect the context for bindings and the operations available for lists.

Terminal window
cai node connect --flow "<flowId>" --from "<sourceNodeId>" --to "<nodeId>" --json
cai expr context "<nodeId>" --prop "<name>" --json
cai expr ops --type list --json

Copy each binding’s js spelling and check inputId and sourceNodeId. If you guess names, the expression can select the wrong source and still compile. unwiredUpstreamOutputs lists connection candidates, not usable bindings. Renaming an input can change its current jsBinding. The legacyBinding value provides diagnostic information. After renaming, wiring, or schema changes, reread context.

MCP uses node_connect with flow, from, and to, expr_context with nodeId and prop, and expr_operation_list with type. Regardless of the CLI pin, every scoped MCP call requires workflowId: "<workflowId>" as a positive-integer string.

SourceUse it forScope
flow.<name>Caller inputCurrent flow.
inputs.<name>Upstream resultWired bindings in context; use the returned accessor.
process.data, process.errorThis node’s result or errorOnly when context exposes the process result.
workflowData("custom.invoice")Stored invoicesActive collection for the run’s workflow version.
itemCurrent list itemOnly where context exposes it; a child flow uses its own flow input.
now, isLiveVersionDate/time or environmentAliases: currentDate, live.

If context exposes flow.title, use a backtick template to produce text.

`${flow.title ?? "Daily invoice digest"}`

For numeric or boolean destinations, use direct expressions such as flow.count ?? 0 or flow.enabled ?? false. ?? requires a text, number, or boolean value and a literal fallback of the same type. It preserves real 0 and false. Text fallback replaces whitespace-only text. Text .isEmpty() treats whitespace as present. On an output.T value, take .data before applying inner operations or fallbacks.

You cannot construct output objects or call JSON.stringify(). The object-literal exception is the .filter() options object, such as { ignoreEmptyConstraints: true }. To produce JSON text, assemble complete tokens: .jsonEncode() supplies text quotes and escapes, and .stringify() serializes shapes or lists.

`{"title":${flow.title.jsonEncode()},"invoices":${inputs.invoiceQueue.stringify()}}`

Substitute the bindings from context. Do not add quotes around the interpolations.

Use three test rows: INV-104 / 120 / review false, INV-105 / 1500 / review true, and INV-106 / 80 / review false. These are example values, not existing records. If context exposes inputs.invoiceQueue and these fields, save this as digest.js to filter and format the digest.

const pending = inputs.invoiceQueue.filter(row => row.reviewNeeded === true);
`Review queue (${pending.length}): ${pending.map(row => row.invoiceId).join(", ")}`

Expect Review queue (1): INV-105. .map() selects one field per call. It cannot construct objects or transform primitive items. Chain selections for nested fields. This single message needs no loop.

For stored invoices, start with workflowData("custom.invoice").filter(...) so supported constraints narrow the storage search. The default record ceiling is 10,000. If you exceed it, you receive a warning and the full result by default. On deployments that enforce the ceiling, the search fails. Narrow the source before relying on a large result.

On a typed null list, both .isEmpty() and .isNotEmpty() return false. Filtering by fields can also fail. Require the input or make its source return []. Test null separately from an empty list.

For statuses "approved", "review", and "":

PredicateMatchesMissing-value consequence
row.status === ""Only ""Null is different from empty text.
row.status !== """approved", "review"A missing value also passes.
row.status.includes("")All threeEvery text string contains empty text.

If blank means “omit this constraint,” open Filtered → Ignore empty constraints in the app, or write .filter(row => row.status === flow.status, { ignoreEmptyConstraints: true }). This skips null, undefined, empty text, and empty-list comparison values. It keeps 0, false, whitespace, and dates.

When the schema is known, an unknown field fails compilation. If a saved or schema-less constraint reaches runtime after its field disappears, .filter() drops it. With no remaining constraints, every row passes. .every() fails closed.

The assistant syntax rejects predicates on primitive lists, such as x => x > 1000. If you invent x.value, the constraint may be dropped. In the app, use Filtered → Add advanced condition and compare the current item. With CLI or MCP, use a per-item flow.

Before assigning an expression to an array property, inspect its metadata. Expressions for whole lists need allowsExpressionMode and the expressionValue channel. Without it, a SHORT_TEXT_ARRAY scalar expression replaces the entire array with one dynamic item. Other array properties without this support accept static lists only.

Validate and save the expression, then read back the saved expression.

Terminal window
cai expr validate "<nodeId>" --prop "<name>" --js-file digest.js --json
cai expr set "<nodeId>" --prop "<name>" --js-file digest.js --json
cai expr decompile "<nodeId>" --prop "<name>" --json

MCP uses expr_validate, node_prop_set, and expr_decompile with workflowId, nodeId, and prop. For validation and setting, pass the file contents in js.

If you receive UNREPRESENTABLE_STORED_EXPRESSION, the expression was not overwritten. The stored expression contains constructs this syntax cannot express. To preserve them, save the selected data.props[] entry’s value or expressionValue as <path>. Match the guarded valueJs or expressionValueJs channel and edit that file. Using --force intentionally loses those constructs.

Terminal window
cai node set "<nodeId>" --prop "<name>" --raw-expression-json "<path>" --json

MCP uses node_prop_set with the parsed file in rawExpressionJson.

When you write a disabled optional property, the setter enables it before compiling. If the write fails, the property stays enabled. If it should remain unused, disable it in the app or with this command.

Terminal window
cai node disable-prop "<nodeId>" --prop "<name>" --json

MCP uses node_optional_prop_set with prop and enabled: false.

In the app: click Test (tooltip: Test current flow) and inspect the node execution’s Configurations. A development test still sends real Slack messages. Choose the intended docs-digest-test channel first.

With an assistant: inspect accepted inputs and save the test values under their returned IDs in digest-test.json. Only when the flow needs no inputs should you use {}. Run the flow in development to inspect its resolved configurations.

Terminal window
cai run inputs "<flowId>" --flow --json
cai run flow "<flowId>" --target dev --inputs digest-test.json --json

For the flow test, MCP uses run_inputs with nodeIdOrFlowId and flow: true, then run_flow with flowId, target: "dev", and parsed inputs.

MCP runs wait waitSeconds (default 60, maximum 120). After finished: false, poll execution_get with workflowId and the returned workflowExecutionId as strings.

Repeat with no qualifying rows. To test missing inputs in development, supply their exact keys with null. If you leave a key out, the run reuses its saved test value. Manual Test turns null text into "". Outside manual Test, omitted optional inputs become null. Check comparisons on the intended trigger/live path too.

Inspect data.nodeExecutions[].resolvedConfigs in the flow result. A node-only result has data.resolvedConfigs. Compilation cannot prove the source ran. A test of one node does not run upstream nodes. Note the destination’s data.nodeExecutions[].id as <nodeExecutionId>. Retrieve that execution to inspect its full details.

Terminal window
cai exec node "<nodeExecutionId>" --full --json

MCP uses execution_node_get with workflowId, string nodeExecutionId, and full: true.