Skip to main content
A state is a declared JSON document with one instance per subject. The platform owns the store: a conversation turn, a hook fire, a scheduled job, a builtin tool, and the HTTP API all read and write the same subject’s document through one seam, and every write lands in one audit trail. Domain behaviour — which flow reads which state, how a record is shaped for a use case — lives in the consumer that binds the state, never in the store. The store is a first-class platform component. It ships with the platform but serves nothing until its database is bound (see Enabling the store); until then every read and write refuses loudly with a 501 rather than returning an empty document.

The subject

A subject is four fields:
  • target_kind and target_name are the conversation-target scope — the only tenancy the platform has. A subject never crosses this pair.
  • kind names the subject family the state serves. Kind person is the one platform-validated family: its key is a person id resolved against the conversations identity store, and the resolved person’s target must equal the subject’s target. Every other kind is declared by the state and its key is opaque to the platform.
  • key addresses one subject within its kind. It is trimmed and must be 1 to 512 characters.
The store refuses a subject loudly (422) when its rule fires: an undeclared kind, an empty key, an unknown person, or a person whose target does not match the subject’s. A subject kind is a lowercase identifier of at most 63 characters (^[a-z][a-z0-9_-]{0,62}$).

Declarations and kinds

A state is declared before it is written. A declaration names the state, its base JSON Schema, the subject kinds it serves, and the default kind a door’s ambient subject resolves to:
  • subject_kinds is a non-empty, unique list; each entry matches the subject-kind charset above.
  • default_subject_kind must be one of subject_kinds. It is the kind a door’s ambient subject resolves to when no explicit kind is named (see Doors).
  • retention_days is optional: a positive integer, or unset to keep records forever.
  • A state name matches ^[a-z0-9][a-z0-9_-]*$ — no colon, because the write op-id namespace splits on :.
Two schema fields are computed by the platform and served on every read: effective_schema (the base schema composed with every attached template’s fragment) and regimes (the absolute write-regime rules composed over the attachments). A client that supplies a non-null value for either on a write is refused — neither can be forged across the wire.

Re-declaring

Once records exist, a re-declare accepts only additive schema changes: a re-declare that would remove or change an existing field while records exist is refused (409), and removing a subject kind still present in records is refused the same way. retention_days is metadata, not schema, so changing it alone is never gated.

Templates and attachments

A state template is a reusable schema fragment with fillable parameters, per-path writer regimes, attach-time declarations, a set of template jq programs, an optional reconcile program, and a trace switch. It is a document the platform owns:
A state template is data and the store is generic: nothing in a template is a use-case feature — it is a mechanism any state can reuse. A name is a slug^[a-z](?:[a-z0-9]|-(?=[a-z0-9])){0,62}$, so no leading, trailing, or consecutive hyphens — because the jq prelude maps -_ to build the tjq_<template>__<name> handle, and only a slug makes that encoding unambiguous. A document that carries keys outside its own schema is refused, so an operator who pastes a whole consumer document is told exactly where those belong.

Template jq

A template’s template_jq is a map of named jq programs a door or a flow node runs against a subject’s record, each declaring a purpose:
  • input{description, purpose: "input", params, jq}. jq runs over the record subtree at the attach path (. is the attached data) with the effective $parameters, the $declarations, and its declared params delivered as the single object $params, and returns a value. Read-only.
  • update{description, purpose: "update", params, reads, writes, jq}. jq’s input is {record, input}.record the current data at the attach path, .input the caller’s argument — with $parameters/$declarations bound, and returns an ordered, template-relative op batch (the keyed-op shape the store applies). params are the key names the .input object must carry — uniform with an input program’s params — so apply refuses an .input missing a declared key or carrying an undeclared one (422); a named update that declares params needs an adapter in a binding to fill them. reads/writes are template-relative paths declared for readability; at put each reads/writes path is validated only for structure — that it resolves against the fragment — while the write regime (that a writes path lies under a declared-writable regime) is enforced at apply by the store guard.
Sibling input programs are ordered dependency-first, so one may call another by name. A binding on any door (or a babelfish node) injects an input program’s value into a run’s input before the run and applies an update program’s batch after it; the record-level HTTP and CLI surfaces evaluate an input program and apply an update program directly (see Template jq).

Attaching

Attaching a template places its fragment at a path in a state’s document. The template is named in the URL segment (PUT …/attachments/{template}), never in the body; the body carries only path/parameters/declarations/options (the last a per-operation, never-stored bag):
The effective schema is the base schema with every attached fragment composed in. Composing two fragments at overlapping paths, attaching a second template at an already-occupied path, or attaching where the base schema already carries the property is a conflict (409) naming the colliding path. A consumer may register an attach validator on the states facet with register_attach_validator: given the template document, the attachment’s declaration values, and the state’s effective schema, it raises loudly to refuse an attach or a declarations write — it is consulted before every attach and every declarations write.

Attach reconciliation

Replacing a live attachment’s declarations can retire part of a template while the state still holds OPEN records shaped by the old declarations. The platform runs a built-in reconciler that reads the template’s reconcile program, and a consumer may register its own with register_attach_reconciler(reconciler) — a pre-write hook run inside every attach and update_attachment_declarations write, after the validators and before the write. It receives an AttachReconcileContext: the state name, the template document, the operation (attach | update_declarations), the previous_declarations (None on a first attach) and new_declarations, the attach options, and a record door bound to the state. The reconciler either raises to refuse the attach (naming the records the new declarations would orphan) or writes resolutions through the context’s record door and returns, letting the attach commit with those writes. The options bag on the PUT/PATCH attach body (AttachBody.options, --options on the CLI) is a free-form, per-operation directive bag the reconciler reads to decide how to settle open records against the new declarations — for example, whether to close records the new declarations no longer cover. It is passed to the reconcilers for THIS attach only, never stored or served back; any key outside path/parameters/declarations/options is refused.

Detaching

Detaching a template is guarded so a live reference is never stranded. The detach door consults every registered detach referee before it removes the attachment and refuses (409, listing the human-readable references each referee returns) while any binding still calls the template’s template jq. The platform’s own binding holders — preset versions, conversation-route configs, hooks, and schedules — are checked, and any consumer that registers a referee is too: the flow engine registers one for its per-node bindings, just as it does against a state delete. Nothing is auto-detached — the author removes or rewrites the referencing bindings first, then detaches. A consumer registers a referee with register_detach_referee, beside register_delete_referee on the tools facet.

Regimes

A regime is a per-path writer rule composed over a state’s attachments. Each rule is a {path, regime} where the path may use the "*" wildcard (one list index or key) and the regime is one of:
  • free — no restriction. An undeclared path is free.
  • composing — the path is built up by many writers, so a whole-path set/remove over it is refused (422); it admits only a keyed op or an append set. The refusal names the path and template, for example: composing path ['items'] of template 'utilities' admits only keyed ops or an append set.
  • single — a single-writer path. This is enforced as an authoring check when a consumer binds a state, not at the store: a hook, schedule, or builtin-tool write on a single path is recorded in the audit trail with its door and actor, never refused at write time.
When a template’s trace switch is enabled, the effective schema admits a _trace object on every write under the attachment, and the platform stamps it (see The audit trail).

Doors

Every door that can write a subject’s document deposits one ambient StateContext — the entering door, the resolvable subject candidates, and the accountable actor — so a downstream write resolves its subject and provenance without a per-door argument. The context is one generic object, so a run parked at an async ask_user and resumed later rejoins the same attribution. A tool run with no ambient context — a sync run-tool call, a background run, the MCP edge — is the api door and a state tool it calls must carry an explicit subject; called with none it raises no subject in scope.

A binding’s subject and scope

A binding — on any door definition (a preset, hook, schedule, or channel route) and, in babelfish, per node — resolves its subject with a jq subject_expr and gates itself with an optional jq scope_expr. Both mean the same on every door and every node:
  • subject_expr is required and non-empty, and yields either a full subject object {target_kind, target_name, kind, key} or a bare key string — for a bare key the kind is the state’s declared subject kind and the target is the run’s ambient target. A missing or empty subject_expr is refused (422).
  • scope_expr is an optional boolean predicate over the run’s inputs, evaluated first: false skips the state for that run — no input injections and no updates; a non-boolean is refused loudly; an absent expression means engaged.
When a door dispatches a preset that carries its own binding, the door’s scope_expr wins when present, else the preset’s. A preset’s own binding fires only when that preset is the outermost dispatch; called as a sub-tool of another dispatch, its binding is a no-op. A binding fires on every door it reaches through, the MCP edge included: an MCP tools/call that omits arguments still receives the binding’s input injections — absent arguments are treated as an empty argument object, never a skipped injection.

The turn subject block

Every tool turn’s payload carries a generic turn block so a flow’s subject_expr can read the subject the platform resolved:
person is null off a non-multichannel target (a person is resolved only for multichannel targets); thread is always present.

Hook and schedule subjects

A hook may declare an optional subject: the target scope, a subject kind, and a key_expr — a jq expression evaluated over the event payload at fire, which must yield a non-empty string, else the fire fails loudly like any hook error. A schedule carries its subject in the job’s tool kwargs; a malformed subject is refused when the job is created (invalid schedule subject: …), so a job that could never resolve its subject is never persisted. Both surface a Subject group on their Studio forms; see Hooks and the scheduling reference.

The builtin tools

Four LLM-facing tools read and write the calling subject’s document. Each resolves its subject from the ambient context, or from an explicit subject argument ({target_kind, target_name, kind, key}, or {kind, key} with the target taken from the context); with neither resolvable it refuses, naming the state, kind, and door. The tools supply only the consumer half of a write’s provenance (their tool name, run, and — for state_apply — the idempotency op_id); the door, actor, and turn id are stamped by the platform, never derived by the tool.

The audit trail

Every write is recorded. A state’s writes trail is a keyset page {items, next_cursor} — the same paging idiom as subjects and record search — with each items row carrying the write’s seq and timestamp at, the completed origin that produced it, and the absolute paths it touched. next_cursor feeds the next call (?cursor=) and is null on the last page. The origin carries what the consumer supplied — consumer, meta (an opaque object the writing consumer supplies — its own identifiers, for instance — stored and shown as is), run_id, op_id — and, stamped by the platform write chokepoint from the ambient context, door, actor, turn_id, and inbound_id. Because door, actor, and turn are stamped by the platform, the trail cannot be forged by a consumer. Under a traced attachment the platform stamps a _trace object on every write: at is always present; run, turn, and inbound are the run, turn, and inbound ids, or null when the writer has none (a hook, a schedule, the API, or a builtin state_* tool has none); and meta is an opaque object the writing consumer supplies — its own identifiers, for instance — stored and shown as is. A flow’s binding write and a hook’s keyed write get the same stamp.

The HTTP surface

All routes are authed. Templates are a top-level sibling collection and prune is its own route, so no literal segment sits on the /api/states/{name} position — a state literally named templates stays reachable at GET /api/states/templates while GET /api/state-templates lists templates.

States

Records

The subject is four path segments: /api/states/{name}/records/{target_kind}/{target_name}/{kind}/{key}.

Template jq

The record-level jq programs a state’s attached templates declare share one path, the method selecting the purpose: {program} is a program name, or {template}.{name} when a name is shared across the state’s attachments. A GET on an update program, or a POST on an input program, refuses loudly.

Templates and retention

Errors map to status: 501 when the store’s database is unbound (a stable code states-not-configured), 404 not found, 409 exists / in use / conflict, and 422 validation (schema, subject, regime, path, value).

The command line

Two command groups mirror the HTTP surface. tai states manages declarations, attachments, subjects, records, and the audit trail — list, get, put, delete, stats, attachments, attach, get-attachment, update-attachment (its --options carrying the per-operation reconcile directives), detach, subjects, search, read, replace, merge, apply, template-jq (eval an input program, apply an update program), erase, fold, writes, consumers, and prune. tai state-templates manages template documents — list, get, put, delete. Record commands take --target-kind, --target-name, --kind, and --key; apply, replace, and merge read their JSON from --data or a --file. See the CLI reference.

Enabling the store

The store is the states DB registry component. Like the skeleton component it defaults to the default database when its binding is unset, so an ordinary deployment serves the store out of the box. A deployment that wants records in a separate database points the binding elsewhere with TAI_DB_BINDING_STATES, then applies the component’s migration chain with tai db migrate. Until the bound database is configured, every read and write returns 501 states-not-configured. Record retention is opt-in: nothing is deleted until a retention is configured, either per state (retention_days on the declaration) or as a deployment default (STATES_DEFAULT_RETENTION_DAYS). tai states prune runs the sweep.