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.
Built-in types
Section titled “Built-in types”| Type | Notes |
|---|---|
text | Strings. |
number | Integers and decimals. |
boolean | True/false. |
date | Exists 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. |
file | A 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).
Custom types
Section titled “Custom types”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.customeris a shape — any object with the right fields, from any source: an AI response, an HTTP call, a.map()over a list.dataRecord.custom.customeris an actual stored record — a row in the workflow’s data store, with identity:id,created time,updated time.
The rules that follow:
- 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 acustom.*value with no identity — itsidresolves empty, and it will not satisfy adataRecord.*input. - The one implicit widening in the whole type system: a
dataRecord.custom.xis accepted where acustom.xis expected (a stored record is a valid shape). Never the reverse. Same forlist.dataRecord.custom.x→list.custom.x. - 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.
Records as references
Section titled “Records as references”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.
How node output types get resolved
Section titled “How node output types get resolved”Downstream expressions can only be written against a node whose output type is known. There are three ways a node gets typed:
- Static contract — over a hundred built-in actions declare their output type in the registry; they are fully typed before any run.
- 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. - 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.
Strictness
Section titled “Strictness”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.