501 rather than returning an empty document.
The subject
A subject is four fields:target_kindandtarget_nameare the conversation-target scope — the only tenancy the platform has. A subject never crosses this pair.kindnames the subject family the state serves. Kindpersonis the one platform-validated family: itskeyis 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.keyaddresses one subject within its kind. It is trimmed and must be 1 to 512 characters.
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_kindsis a non-empty, unique list; each entry matches the subject-kind charset above.default_subject_kindmust be one ofsubject_kinds. It is the kind a door’s ambient subject resolves to when no explicit kind is named (see Doors).retention_daysis optional: a positive integer, or unset to keep records forever.- A state
namematches^[a-z0-9][a-z0-9_-]*$— no colon, because the write op-id namespace splits on:.
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: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’stemplate_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}.jqruns over the record subtree at the attach path (.is the attached data) with the effective$parameters, the$declarations, and its declaredparamsdelivered 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}—.recordthe current data at the attach path,.inputthe caller’s argument — with$parameters/$declarationsbound, and returns an ordered, template-relative op batch (the keyed-op shape the store applies).paramsare the key names the.inputobject must carry — uniform with aninputprogram’sparams— so apply refuses an.inputmissing a declared key or carrying an undeclared one (422); a named update that declaresparamsneeds an adapter in a binding to fill them.reads/writesare template-relative paths declared for readability; at put eachreads/writespath is validated only for structure — that it resolves against the fragment — while the write regime (that awritespath lies under a declared-writable regime) is enforced at apply by the store guard.
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):
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’sdeclarations 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 isfree.composing— the path is built up by many writers, so a whole-pathset/removeover it is refused (422); it admits only a keyed op or an appendset. 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 asinglepath is recorded in the audit trail with its door and actor, never refused at write time.
_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 ambientStateContext — 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 jqsubject_expr and gates itself with an optional jq scope_expr. Both mean the same
on every door and every node:
subject_expris 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 thekindis the state’s declared subject kind and the target is the run’s ambient target. A missing or emptysubject_expris refused (422).scope_expris an optional boolean predicate over the run’s inputs, evaluated first:falseskips the state for that run — no input injections and no updates; a non-boolean is refused loudly; an absent expression means engaged.
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 genericturn 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 subjectkind, 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 explicitsubject 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’swrites 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 thestates 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.
