Skip to content

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.

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.

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

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

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

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

Terminal window
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" --json
cai data fields --type custom.invoice --json

Before 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".

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.

Terminal window
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"}' --json

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

Terminal window
cai data query --type custom.invoice --env dev --query "INV-104" --limit 50 --offset 0 --json
cai data get "<recordId>" --type custom.invoice --env dev --json

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

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.

Terminal window
cai data update "<recordId>" --type custom.invoice --env dev --fields '{"status":"approved"}' --json
cai data get "<recordId>" --type custom.invoice --env dev --json

MCP uses data_record_update with recordId, type: "custom.invoice", env: "dev", and fields: {"status":"approved"}.

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.

Terminal window
cai flow list --json
cai integration actions "Update data" --integration registry_data --json
cai node add --flow "<flowId>" --node registry_data --key "<componentKey>" --json

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

Terminal window
cai node set "<nodeId>" --prop dataType --value custom.invoice --json
cai node props "<nodeId>" --json
cai expr context "<nodeId>" --prop dataRecord --json
cai data query --type custom.invoice --env dev --query "docs-invoice-104" --limit 100 --offset 0 --json

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

Terminal window
cai expr validate "<nodeId>" --prop dataRecord --js-file invoice-record.js --json
cai expr set "<nodeId>" --prop dataRecord --js-file invoice-record.js --json
cai node enable-prop "<nodeId>" --prop status --json
cai node set "<nodeId>" --prop status --value reviewed --json
cai node props "<nodeId>" --json
cai run node "<nodeId>" --json
cai data get "<recordId>" --type custom.invoice --env dev --json

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

ActionEmpty or missing recordsResult
Get dataEmpty, missing, deleted, or wrong collectionnull.
Update data / Delete dataEmpty referenceFails: Data record is empty — no matching record was found.
Update multiple data recordsEmpty 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.

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.

Terminal window
cai schema get custom.invoice --json
cai schema update custom.invoice --display "Invoices" --json
cai schema get custom.invoice --json
cai data get "<recordId>" --type custom.invoice --env dev --json

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