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.
Choose the source before writing
Section titled “Choose the source before writing”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.
cai workflow list --limit 100 --jsonNote 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.
cai use "<workflowId>" --jsoncai flow list --jsonNote the selected data.items[].id as <flowId>. Inspect that flow to find its source and destination nodes.
cai flow get "<flowId>" --jsonFrom data.nodes[].id, note the source as <sourceNodeId> and destination as <nodeId>. List the destination’s properties to find the text property’s name.
cai node props "<nodeId>" --jsonUse 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.
cai node connect --flow "<flowId>" --from "<sourceNodeId>" --to "<nodeId>" --jsoncai expr context "<nodeId>" --prop "<name>" --jsoncai expr ops --type list --jsonCopy 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.
| Source | Use it for | Scope |
|---|---|---|
flow.<name> | Caller input | Current flow. |
inputs.<name> | Upstream result | Wired bindings in context; use the returned accessor. |
process.data, process.error | This node’s result or error | Only when context exposes the process result. |
workflowData("custom.invoice") | Stored invoices | Active collection for the run’s workflow version. |
item | Current list item | Only where context exposes it; a child flow uses its own flow input. |
now, isLiveVersion | Date/time or environment | Aliases: currentDate, live. |
Write text without losing types
Section titled “Write text without losing types”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.
Filter and format one digest
Section titled “Filter and format one digest”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.
Check empty filters deliberately
Section titled “Check empty filters deliberately”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 "":
| Predicate | Matches | Missing-value consequence |
|---|---|---|
row.status === "" | Only "" | Null is different from empty text. |
row.status !== "" | "approved", "review" | A missing value also passes. |
row.status.includes("") | All three | Every 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.
Save and inspect the actual value
Section titled “Save and inspect the actual value”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.
cai expr validate "<nodeId>" --prop "<name>" --js-file digest.js --jsoncai expr set "<nodeId>" --prop "<name>" --js-file digest.js --jsoncai expr decompile "<nodeId>" --prop "<name>" --jsonMCP 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.
cai node set "<nodeId>" --prop "<name>" --raw-expression-json "<path>" --jsonMCP 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.
cai node disable-prop "<nodeId>" --prop "<name>" --jsonMCP 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.
cai run inputs "<flowId>" --flow --jsoncai run flow "<flowId>" --target dev --inputs digest-test.json --jsonFor 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.
cai exec node "<nodeExecutionId>" --full --jsonMCP uses execution_node_get with workflowId, string nodeExecutionId, and full: true.