Store Workflow Data
How do you keep an invoice after its flow finishes? Define a custom data type and create a record in its collection. A flow result or AI response alone does not store a record.
An invoice’s data shape is custom.invoice. The dataRecord.custom.invoice type adds stored identity and timestamps. Update and delete actions need that identity.
Your records are separate in Development and live. Development node and flow tests use development records. Published runs use live records. Publishing synchronizes schemas without copying rows. A flow can therefore pass Test and find no row live.
Define fields with stable IDs
Section titled “Define fields with stable IDs”In the app: open Workflows → Invoice intake → Data tables → Add data type. Name it Invoice and define fields with Field name, Data type, and Add field. Choose Create. The app generates field IDs from names and types. For the fixture’s exact IDs, use the CLI below.
With an assistant: list workflows to find Invoice intake.
cai workflow list --limit 100 --jsonNote Invoice intake’s data.items[].id as <workflowId>. If data.hasMore is true and Invoice intake is absent, find its ID in the app.
After pinning the workflow with cai use, subsequent commands omit --workflow. Every scoped MCP call needs workflowId: "<workflowId>", a positive-integer string.
Pin the workflow and list its existing schemas.
cai use "<workflowId>" --jsoncai schema list --jsonMCP uses schema_type_list to inspect existing schemas.
Each --field uses id:type[:display label]. If you omit the display label, it defaults to the ID. If custom.invoice is absent, create its schema and collection, then inspect its fields.
cai schema create custom.invoice --display "Invoice" \ --field invoice_id:text:"Invoice ID" \ --field amount:number:"Amount" \ --field status:text:"Status" \ --field review_needed:boolean:"Review needed" \ --field submitted_at:date:"Submitted at" \ --field source_event_id:text:"Source event ID" --jsoncai data fields --type custom.invoice --jsonBefore reusing an existing type, check data.fields[].id and compatible type values. If IDs differ, substitute them in every later write. Direct writes use field IDs. Reads and CSV columns use display labels, plus id, created time, and updated time. Do not pass a query result unchanged into a write.
MCP uses schema_type_create with name: "custom.invoice", display: "Invoice", and field containing the six specification strings. Use data_field_list with type: "custom.invoice".
Create and read one invoice
Section titled “Create and read one invoice”In the app: on Store invoice, choose Add action → Create data and select Invoice in Data type. Fill the generated fields using this fixture. Test the development flow, then inspect Data tables → Invoice. Data tables also lets you delete, import CSV, and copy development records to live. It has no form for creating or editing individual records.
With an assistant: create the fixture to get a stored invoice record.
cai data create --type custom.invoice --env dev \ --fields '{"invoice_id":"INV-104","amount":120,"status":"received","review_needed":false,"submitted_at":"2026-09-07T14:00:00Z","source_event_id":"docs-invoice-104"}' --jsonNote data.record.id as <recordId>, separate from the business field invoice_id. When writing non-null dates, use valid ISO-8601 strings. Impossible dates are rejected.
Every create inserts a new row. Retries can duplicate Invoice ID or Source event ID. Neither field is unique. A lookup followed by create does not prevent concurrent duplicates.
Search for the invoice and retrieve the stored record by its ID.
cai data query --type custom.invoice --env dev --query "INV-104" --limit 50 --offset 0 --jsoncai data get "<recordId>" --type custom.invoice --env dev --jsonInspect data.totalCount and advance --offset for further pages. The page size defaults to 50 and cannot exceed 100. Queries search record IDs, timestamps, and serialized fields for text, ignoring case. It is not a field predicate. Missing, empty, or whitespace-only queries return an unfiltered page. If the record is absent, you get data.record: null.
MCP uses data_record_create, data_record_query, and data_record_get with type, env: "dev", parsed fields for create, and recordId for get. Writes require an explicit env.
Patch only the intended fields
Section titled “Patch only the intended fields”When you patch records directly, omitted fields keep their values. null and "" overwrite values. Even {} advances updated time. Update the intended fields and read back the same record from the same store.
cai data update "<recordId>" --type custom.invoice --env dev --fields '{"status":"approved"}' --jsoncai data get "<recordId>" --type custom.invoice --env dev --jsonMCP uses data_record_update with recordId, type: "custom.invoice", env: "dev", and fields: {"status":"approved"}.
Configure a native update
Section titled “Configure a native update”In the app: choose Add action → Update data and select Invoice. Open Data record → Insert dynamic data → Search for data. Select Invoice, filter Source event ID to docs-invoice-104, and take the first item. Enable only Status under Optional inputs and set it to reviewed. The expression supplies a stored record. Confirm only the intended invoice matches. Choose Test action from the node menu and inspect Data tables → Invoice.
With an assistant: find Store invoice and Update data to add the action to the flow.
cai flow list --jsoncai integration actions "Update data" --integration registry_data --jsoncai node add --flow "<flowId>" --node registry_data --key "<componentKey>" --jsonNote Store invoice’s data.items[].id as <flowId> and Update data’s data.items[].key as <componentKey>.
Note data.nodeId as <nodeId>.
MCP uses integration_action_list with query and integration. Then use node_add with flow, node, and the returned key.
Before configuring properties, set the type and reread regenerated properties, expression context, and matching records.
cai node set "<nodeId>" --prop dataType --value custom.invoice --jsoncai node props "<nodeId>" --jsoncai expr context "<nodeId>" --prop dataRecord --jsoncai data query --type custom.invoice --env dev --query "docs-invoice-104" --limit 100 --offset 0 --jsonConfirm the collection in data.workflowData. Inspect every query page: advance --offset by 100 until you have examined data.totalCount records. Require exactly one row whose Source event ID equals docs-invoice-104, with id equal to <recordId>. Save the expression as invoice-record.js to select that record.
In MCP, use node_prop_set, then node_prop_list, then expr_context, with nodeId and the shown prop/value where applicable.
workflowData("custom.invoice").filter(row => row.sourceEventId === "docs-invoice-104").first()Validation warns that this expression returns custom.invoice while the property expects dataRecord.custom.invoice. The compiler uses the shape name for this search. At runtime, you get stored records with identity. Require no validation errors and verify the returned record ID.
Validate and save the expression, enable Status, and test the update to read back the record.
cai expr validate "<nodeId>" --prop dataRecord --js-file invoice-record.js --jsoncai expr set "<nodeId>" --prop dataRecord --js-file invoice-record.js --jsoncai node enable-prop "<nodeId>" --prop status --jsoncai node set "<nodeId>" --prop status --value reviewed --jsoncai node props "<nodeId>" --jsoncai run node "<nodeId>" --jsoncai data get "<recordId>" --type custom.invoice --env dev --jsonCheck data.resolvedConfigs and confirm the saved Status is reviewed. Native updates write every enabled field except undefined. Before testing, disable unwanted fields.
MCP uses expr_validate and node_prop_set with inline js, node_optional_prop_set with enabled: true, and run_node with nodeId.
After checking the binding, use .filter(row => row.invoiceId === flow.invoiceId) to select by business ID. Reject an empty Invoice ID. The comparison === "" matches empty fields, !== "" passes missing values, and includes("") matches every text value. .first() selects the earliest match by creation time, then record ID. With no match, it returns null. Equality does not imply uniqueness.
| Action | Empty or missing records | Result |
|---|---|---|
| Get data | Empty, missing, deleted, or wrong collection | null. |
| Update data / Delete data | Empty reference | Fails: Data record is empty — no matching record was found. |
| Update multiple data records | Empty list; duplicate IDs | []; each distinct ID updated once. |
If a record is missing, deleted, or in another collection, multi-update commits none of the batch. Otherwise, it commits the batch. Earlier nodes keep their effects. Before a full rerun, inspect all earlier effects on data and external services. Verify that repetition is safe or choose a smaller scope. Delete returns {id, deleted: true} and removes the row from normal reads. There is no record undo. Workflow restore cannot recover deleted or overwritten rows.
Preserve access when fields change
Section titled “Preserve access when fields change”In the app: choose View data type → Edit data type → Save and apply. Changing labels preserves field IDs and values. It changes query/get keys and CSV headers. Update consumers of the old labels.
With an assistant: changing only the display preserves fields. Supplying --field or --from-json replaces the complete field list.
Inspect the schema, change its display, and read back the schema and record.
cai schema get custom.invoice --jsoncai schema update custom.invoice --display "Invoices" --jsoncai schema get custom.invoice --jsoncai data get "<recordId>" --type custom.invoice --env dev --jsonMCP uses schema_type_get with name: "custom.invoice", then schema_type_update with that name and display: "Invoices". Supplying field or fromJson replaces the field list.
On old rows, new fields read as null. Removing a field hides its values. Restoring the same ID with a compatible type exposes them. Changing field types does not convert values. Incompatible values read as null. Changing a custom.* key creates a different collection. Rows stay under the old key. Deleting a type also removes fields referencing it from other types. Recreating it does not restore those fields. Revalidate affected expressions immediately. When a field is removed, .filter() drops its constraint and can retain every row. After removal, .every() returns false.
For moving rows between stores, see Import and Copy Records.