Skip to content

The type system

Every value in a workflow has a type. Types drive validation (a wired mismatch fails loudly, before anything runs), autocomplete, and which operations an expression offers. This page is the model; the full operation catalog lives in the expression operations reference.

TypeNotes
textStrings.
numberIntegers and decimals.
booleanTrue/false.
dateExists at runtime — the current date/time source, date operations, date fields written via the API — but is not selectable in the builder’s type dropdowns for flow inputs, custom-type fields, or AI response formats. Store timestamps as text and convert with date operations when needed.
fileA Controller-hosted file with metadata: id, file name, file type, MIME type, size, URL. A real first-class type with its own operations.

Two wrappers compose with everything: list.<type> (a list of that type) and dataRecord.<type> (see below).

A custom type describes a structured object — a lead, a ticket, an invoice. Its identity is a sanitized machine key derived from the display name once, at creation: display name “Customer” becomes custom.customer. Renaming the display name later does not change the key. Fields work the same way: each has a display name and a stable field id (auto-generated ids look like customer_name_text).

This display-name/machine-key split matters operationally: writes key by field id, reads key by display name. A record you queried comes back keyed by display names; feeding it directly into a write (which expects field ids) drops or rejects fields. The data pages cover the practical handling.

Every record also carries three reserved fields — id, created time, updated time — and user fields cannot collide with them.

The distinction that matters: shape vs stored record

Section titled “The distinction that matters: shape vs stored record”

custom.customer and dataRecord.custom.customer are different types, and the difference has teeth:

  • custom.customer is a shape — any object with the right fields, from any source: an AI response, an HTTP call, a .map() over a list.
  • dataRecord.custom.customer is an actual stored record — a row in the workflow’s data store, with identity: id, created time, updated time.

The rules that follow:

  1. Only a real stored record satisfies a dataRecord.* input. The Update/Delete data nodes need identity to act on. An expression that constructs a record-shaped object produces a custom.* value with no identity — its id resolves empty, and it will not satisfy a dataRecord.* input.
  2. The one implicit widening in the whole type system: a dataRecord.custom.x is accepted where a custom.x is expected (a stored record is a valid shape). Never the reverse. Same for list.dataRecord.custom.xlist.custom.x.
  3. AI response types must be shapes. The Use AI node rejects dataRecord.* as a response format — a model can produce data, not stored identity. Store its output with a Create data node if you need a record.

In the builder UI this distinction appears as the “expect data record with id” checkbox on typed inputs; in the CLI it is explicit in every type id.

In data-store schemas, a custom-typed field is a record reference, not an embedded object: writing it takes a record id (a list.custom.x field takes a list of ids), and reading resolves the link. Embedded nested objects exist only in non-store shapes — AI response formats and integration-derived types.

Downstream expressions can only be written against a node whose output type is known. There are three ways a node gets typed:

  1. Static contract — over a hundred built-in actions declare their output type in the registry; they are fully typed before any run.
  2. Declared — for dynamic nodes too consequential to run blind, declare the contract from the provider’s documented response (cai node declare-output --sample-file shape.json). It shows as declared (unverified) until a real run confirms and upgrades it.
  3. Observed — the first successful run adopts the actual output shape.

Until one of those happens, the output is Unknown, and downstream references cannot be authored. Publishing does not resolve Unknown outputs — it only freezes flow-level schemas computed from types already known.

Beyond the single widening rule above, there is no implicit casting: conversions are explicit operations (converted to text, converted to date, …). A wired type mismatch is a loud pre-run failure. The quiet hazards live elsewhere — expressions null-propagate and empty comparisons fail closed; see the execution model.

Deleting a custom type that expressions still reference is caught by validation (DANGLING_TYPE_REFERENCE) before it bites at runtime — run cai workflow validate after schema surgery.