CONVERSATIONS_* setting with its default.
Every routing operation requires the Redis conversations backend
(CONVERSATIONS_REDIS_URL). With no backend, each one refuses with a loud 501.
Routes: CRUD and CLI
create is an authority-changing, admin-adjacent operation: it binds the
route’s execution_key (a pass-role decision) before any write, so a refused bind
leaves an existing row untouched. The target_name — the agent or tool the turn
runs — must merely exist. A channel row’s (channel, our_identity) pair must be
unclaimed. An api row that declares a callback_url mints a callback_secret
returned once in the create result and never re-readable.
create flags: --door (api|channel), --target-kind (agent|tool, default
agent), --target-name, --execution-key, --payload-expr / --reply-expr (tool
targets), --initial-mode (agent|manual, default agent — the route’s default
control mode), --channel / --identity
(channel rows), --callback-url (api rows, optional, HTTPS). --initial-mode manual is valid on
any target (see control mode).
Route targets: agent or tool
A route’starget_kind chooses what an inbound turn runs:
agent(target_namea registered agent) — the default. The turn is the agent’sastreamon the route’s thread, so the conversation carries memory across messages (see conversation lifetime).tool(target_namea registered tool) — the turn dispatches the tool statelessly: no conversation memory, one message in and one reply out. It runs under the route’sexecution_key, whose grants authorize the dispatch.
tool target shapes the call with two optional jq programs, both compiled at
create (an agent target carries neither):
payload_expr— jq over the inbound payload{message, sender, our_identity, channel, turn}— carryingperson_idandperson_addressestoo when the target’s multichannel is on, andeventon an event turn — emitting exactly one JSON object: the tool’s kwargs. Omitted, the kwargs are the fixed{message, sender, turn}(pluseventon an event turn).person_idis the person the sending address resolves to andperson_addressesits address rows;person_idgives a flow a channel-stable key for a linked person, one and the same across the channels they paired.reply_expr— jq over the tool’s result, emitting exactly one value that is a string ornull: the reply text. Omitted, the result must itself be a string ornull.
form object when the inbound message brought an
ask-less form submission — the structured
copy of the values whose rendered text is the message itself. The key is present
only when the inbound carried one, so a payload_expr over a form-less inbound
sees a byte-identical payload, and the default no-expr kwargs stay the fixed
{message, sender} either way — a route maps the form deliberately or not at all.
An agent target never sees it: an agent reads the rendered text only. The values
are bounded as transport (serialized size and nesting) and never validated against
the form’s schema — participant-shaped data a tool target checks itself. The record
publishes the same object as inbound_form on both the admin and caller-safe reads.
The payload also carries a params object when the inbound entry supplied one —
a web channel’s link parameters captured
with the visitor session, the message door’s params body field,
or a channel’s tap enrichment when the person taps a control the flow offered (a
tapped reply’s author-set id as params.reply_id, a template quick-reply as
params.button_payload, a click-to-chat referral as its referral_* fields — what
each channel forwards is on its plugin page) — string values reachable as
.params.<name>. The key is present only when the entry carried non-empty params,
so guard for it (.params.key // …). The same enrichment rides an
answer’s params when the tap
instead answers a pending ask_user. The values
are untrusted transport: the platform caps and delivers them but attaches no
signature, expiry, or meaning, so a value a flow must trust is a secret the flow
checks in its own store. Bounds: at most 16 params, each key
^[A-Za-z0-9_-]{1,64}$, each value ≤ 512 characters, ≤ 2048 bytes serialized
— a violation is refused at the door.
The payload also carries a turn block on every tool turn — whichever door
it entered through:
turn.id is the turn’s stable id — the answer record’s message_id, not a new mint.
turn.inbound describes the entry: inbound.kind is "message" or "event",
inbound.id is the channel provider message id / the event’s own
event_id / (on the api message door) the record’s message_id, and inbound.source
is the channel registry name / event:<kind> / api. On an event turn the payload
also carries an event block {id, kind, payload} (the submitted event) and both
message and sender are null — an event has no human sender and no text. The
default no-payload_expr kwargs therefore become {message, sender, turn} (plus
event on an event turn); a payload_expr reads .turn and .event off the payload
and forwards what it needs.
A reply of null or blank is a deliberate silence, and how it resolves depends
on the door. On a channel route the record ends in the terminal silent state and
nothing is sent to the client. On an api route the silence is communicated: the
turn rides the normal delivery pipeline and its callback (or sync-wait reply) carries
status: "silent" with no answer text, so the caller still gets a terminal result.
A mapping fault, a denied or failed dispatch, a wrong-typed result, or a tool turn
that ends on a non-success terminal status (aborted, stopped, error) is a
client-safe error outcome instead — reply_expr maps only the success shape, so a
non-success terminal never reaches the mapping and diverts straight to the error reply.
A flow preset as a tool target
Publish a flow as a named tool (a preset) and route to it to answer each message by running the flow. The flow tool takesflow_graph_kwargs, so
map the message into them and pull the reply out of the result envelope:
.result.answer is the flow’s answer node; // null stays silent when the flow
produces nothing to say. This mapping only ever sees the success shape: if the
flow or tool turn ends on a non-success terminal status (aborted, stopped,
error) the turn diverts to the error outcome and reply_expr never runs.
The whole kwargs object is validated against the preset’s input_schema and routed
into the flow’s flow_graph_kwargs argument, so with no payload_expr a flow preset
reads the default kwargs directly as .flow_graph_kwargs.message, .sender, and
.turn (plus .event on an event turn) — no route jq needed. A
conversation-target flow preset must therefore admit turn (and event) in its
input_schema; one that declares additionalProperties: false without them refuses
the routed kwargs.
Such a flow or tool turn need not answer synchronously: if it raises an async
ask_user, the turn parks and its resumed reply is delivered back into the
originating thread later — mapped through the route’s reply_expr — the same
deliver-back documented for an agent target. A step-mode interrupt is the
exception: a conversation turn cannot drive a step-mode run, so its reply would
never arrive. Rather than silence a pause nothing can resume, the turn diverts to
the loud error outcome, naming the misconfiguration in the recorded detail — route
a step-mode tool to a conversation and every inbound fails this way.
The message door
POST /api/conversations/{route_name}/messages (authed) accepts one API-door
message and runs its turn as the route’s execution key. Body: external_user_id,
text, optional wait_seconds, optional params, optional form, optional
attachments, optional location.
form is an optional structured participant submission (an
ask-less form’s answers) riding with
the required text — the text stays the carrier every consumer sees, while a tool
target’s payload gains the structured copy under its form key. It must be a JSON
object within the transport bounds (serialized size and nesting); a violation is a
loud 400, and the refusal never echoes the submitted values. Nothing validates the
object against any schema — it is participant data.
params is an optional object of string values delivered to a tool target’s
payload under .params — the same key a channel entry’s link parameters use — for a
uniform tool-payload contract across both doors. It is validated to the same bounds
(at most 16 entries, key ^[A-Za-z0-9_-]{1,64}$, value ≤ 512 characters, ≤ 2048
bytes); an invalid set is a 400 that names the violated bound without echoing the
offending value.
attachments and location are the media and geographic siblings of form — a list
of MediaItems (image/document/video/audio) the caller sent with the text, and a
shared LocationElement (latitude/longitude with an optional name/address). Each
rides beside the required text (which stays the whole turn every reader
consumes) and reaches a tool target’s payload under its own attachments /
location key, present only when supplied. attachments obeys the shared media caps;
both are untrusted transport, never schema-validated. See
structured participant messages.
202 {message_id, thread_id}— accepted; the answer is delivered later by the route’s signed callback, or — when the route declares nocallback_url— read back fromGET /api/conversations/{route_name}/messages/{message_id}.200with the answer inline — await_secondsturn that finished in time; a route with a callback suppresses it so it never double-fires. A turn that finished silent returns200with the silent marker inline (status: "silent", no answer text).wait_secondsis clamped tosync_wait_max_seconds.- Refusals are loud:
400bad body,401unauthorized,404unknown route,429address rate cap,501no backend,503thread queue full.
The event door
POST /api/conversations/{route_name}/events (authed) delivers a structured event
into an existing thread as a turn. An event carries no human text: it runs the
route’s tool target through the same per-thread FIFO an inbound message takes — as a
turn, never a detached tool run — so it is ordered behind any
message turn already on the thread. It never mints a thread. On a linked-person thread the
event turn’s payload carries person_id and person_addresses exactly as a message turn
does — the existing person is resolved read-only, never provisioned.
Body (ConversationEventSubmission):
addressXORthread_id— exactly one, non-blank, names the target thread; both or neither is a400.event.event_id— the idempotency key: non-blank after trimming, ≤ 256 characters.event.kind— an identifier-like label matching^[A-Za-z0-9_.:-]{1,128}$(dotted and colon-segmented labels likeprovider.updateare admitted); it becomes the turn’sinbound.sourceasevent:<kind>.event.payload— an opaque JSON object, bounded as pure transport exactly like the message door’sform(a serialized-size and nesting cap); never validated against any schema.wait_seconds— the same bounded sync-wait window as the message door, clamped tosync_wait_max_seconds;0(the default) is an async202.
200with the answer inline — await_secondsturn on an api-door thread that finished in time; a route with a callback suppresses it so it never double-fires.202 {message_id, thread_id}— accepted; the answer is delivered later by the target route’s door (a channel send, an api route’s signed callback, or — when the api route declares no callback — the poll door,GET /api/conversations/{route_name}/messages/{message_id}).404— the thread does not exist on the route (an event enters an existing thread, so an unknown(route, address)orthread_idis refused, never opened).409— the route’s target is an agent: an event has no rendered text to hand an agent, so only atooltarget may run one.400— a bad body: theaddress/thread_idrule, a blank/over-longevent_id, a malformedkind, or an over-boundpayload.- Also loud:
401unauthorized,429address rate cap,501no backend,503thread queue full.
event_id, per route, in its own key family
(conversations:event-dedupe:{route_name}:{event_id}, held for inbound_dedupe_ttl_seconds
— never the channel dedupe namespace). A redelivered event_id starts no second turn
and returns the original turn’s message_id (a 202). A refused or failed admission
— a rate cap, a full queue — never burns the key, so a retry after one runs cleanly.
Thread addressing. The thread must already exist on the route; the platform recomposes
the id through the same function the route’s own door uses, never trusting a caller-composed
key. By address: a channel route composes the address exactly as its channel door
does; an api route qualifies the address by the calling principal (as the message door
does), so by address a caller reaches only its own threads on that route — another
principal’s api thread is reachable only by its listed thread_id. By thread_id: the id
the thread listing or transcript returns,
verified to be a member of the route’s threads.
Delivery follows the target route’s door: a channel route’s answer is sent to the
thread’s address; an api route’s answer is POSTed to the route’s callback_url, signed
with the route’s callback_secret — or returned inline on a bounded wait_seconds, or
recorded only when the route has no callback (retrievable through
GET /api/conversations/{route_name}/messages/{message_id}). There is no per-request
callback field: a caller-named sink would make the platform emit a validly signed POST to
an arbitrary host.
The event door is a trusted-integration surface. Authorization is the conversations
write action with a mandatory caller principal — the same primitive that creates a
route. An authorized writer may address any existing thread of the route by
thread_id, a channel participant’s thread included, so grant the door’s write action to
service principals, not to low-trust API keys.The read doors
The single-record door is a plain authed read gated by the conversations read
grant: a holder reads any record on the route, whichever door it arrived through. An
admin read returns the full record (including
error, the turn’s internal detail); a
non-admin read returns a caller-safe projection that withholds error and the
delivery bookkeeping — the turn ran as the route’s key, not the reader’s. A record
on another route (or missing) is a 404 — a wrong-route or unknown address, never an
authorization verdict.
Every record carries an origin — client for an inbound message’s turn, operator
for a message an operator sent into the thread by
hand — published in both the admin and the
caller-safe projections. A record whose inbound message brought an
ask-less form submission also publishes the
structured object as inbound_form, in both projections, beside the rendered
inbound_text that stays the message every consumer reads.
The failed-delivery listing spans every route and caller, so it is admin-only.
The thread listing
GET /api/conversations/{route_name}/threads lists the route’s threads, newest
activity first, one page at a time. It is admin-only — a listing spans every
caller and every address on the route — and the authorization is decided before the
route is looked up, so a non-admin is refused identically whether the name routes or
not.
The answer is
{items, total, page, page_size, next_page, truncated}, where total counts
the route’s indexed threads (or, under a filter, the matches the scan found), next_page is
null on the last page, and truncated is described below. Each item carries:
status is validated against the delivery-status vocabulary (accepted,
pending_delivery, provisional, delivered, failed, shed, silent); an unknown value
is a loud 400, never a silently ignored filter.
Filters are a bounded post-scan. The per-status indexes are global over message ids, not
per-route thread ids, and an address has no index at all, so
status/address are an
app-side scan bounded by a fixed budget. A page that spends its budget before the route is
exhausted answers truncated: true — matches may lie beyond it. An unfiltered listing
never scans and is never truncated. truncated is always present, so a client reads it on
every page rather than inferring completeness.400 a non-integer, sub-1, or too-large window, or an unknown status; 401
unauthorized; 403 a non-admin caller; 404 an unknown route; 501 no conversations
backend.
Searching a route’s messages
GET /api/conversations/{route_name}/messages/search?q=…
(tai conversations search <route> --q …) returns every record on the route whose inbound
text or answer contains q, across all its threads, newest-active thread first. It is
admin-only (it spans every caller on the route) and each item is the whole record —
the same shape the transcript serves an admin. q is REQUIRED and non-blank.
The answer is
{items, total, page, page_size, next_page, truncated}. There is no per-route
record index, so the search is a bounded nested scan (the route’s threads, then each thread’s
records); a page that spends its budget answers truncated: true. Refusals: a blank/absent
q or a malformed window is 400; a non-admin is 403; an unknown route is 404; no
backend is 501.
The transcript
GET /api/conversations/{route_name}/transcript?thread_id=… reads one thread’s
records, one page at a time. The thread id rides the query string, not the path:
an API-door id carries a percent-encoded principal that no path spelling round-trips,
and a query value is decoded exactly once whatever it holds.
The answer is
{items, total, page, page_size, next_page, order, truncated}, its items the
same answer records the single-record door serves — whole records for an admin, the
caller-safe projection otherwise.
q is a bounded text search over the record content (the searched text lives inside the
record’s JSON blob), so a page that spends its scan budget answers truncated: true. Under
q, total is the number of matches found, and a thread that exists but matches nothing
reads as an empty page, never a 404 — the unknown-thread 404 below is reserved for a
thread the index does not hold at all.bridge:@person:{person_id} thread, or a channel
thread alike. Only the two listings stay admin-only (each spans every caller and
address on the route); a single thread read does not.
A 404 here is an addressing verdict, never an authorization one: an unknown
thread, an expired one, or a route_name that does not route at all. A blank
thread_id, an unknown order, or a malformed window is 400; a caller without the
read grant is 403; no backend is 501.
A thread the index still holds but whose records have expired under
answer_retention_ttl_seconds reads as an empty page carrying the indexed
total — not a 404 — until the prune pass reclaims the members.Forgetting a thread
DELETE /api/conversations/{route_name}/thread?thread_id=…
(tai conversations delete-thread <route> <thread>) forgets one thread
outright — its agent checkpoint, its answer records, and its thread indexes — so a
later message on the same address starts a memory the deleted turns never touched. A
linked person’s aggregated bridge:@person:{person_id} thread is forgotten across
every route index it spans.
The thread id rides the query string, exactly as the transcript door takes it: an
API-door id carries a percent-encoded principal that no path spelling round-trips.
Forgetting is absolute. A valid id on its own route always succeeds, and the
answer’s removed count is 0 when nothing was left to clear — an aged-out thread
whose records already expired under answer_retention_ttl_seconds, or one never seen
— never a 404. The checkpoint is deleted regardless, so keep-forever memory is not
left behind once the records lapse.
Authority is the door’s write action — the same conversations write grant that
creates a route deletes routes and forgets threads, never a per-thread owner check.
The answer is {removed, route_name, thread_id}. Refusals: a route-keyed id missing
the route’s bridge:{route_name}: prefix is a 400 (it names another route) — the
one guard stopping a delete on one route from wiping another’s memory; a
bridge:@person: thread whose person is unknown, or whose route_name is not one of
that person’s routes, is a 404; a turn in flight on the thread is a 409 (retry
once it drains); a blank thread_id or invalid route_name is a 400; no backend
is 501.
Forgetting a person
DELETE /api/conversations/persons/{person_id}
(tai conversations delete-person <person_id>) erases a linked person ENTIRELY — every
store scoped to that identity:
- its aggregated
bridge:@person:{person_id}thread — the agent checkpoint, and across every route the person wrote under its answer records, per-thread transcript indexes, route-thread index memberships and per-thread mode override (the thread-forget machinery, reused); - the person row
conversations:person:{person_id}; - every
person_indexdoor_address_key → person_idmapping of its addresses.
404: its aggregated checkpoint is forgotten regardless (it defaults
to keep-forever) and the call answers erased: false.
The answer is {person_id, removed, erased}, where removed counts the answer records
deleted across the person’s routes and erased says whether this call removed the person
row. Refusals: a turn in flight on the aggregated thread is a 409 (re-checked under the
per-thread FIFO before any teardown, so an admitted turn cannot re-create memory behind the
erase; a full queue is the retriable 503); a blank person_id is a 400; no backend is a
loud 501.
The erase covers the bridge’s stores only. The person’s run traces live in the
monitoring backend, attributed by a
user_id that is the person_id (or the raw
address for an unlinked subject) — deleting them there is your
duty, keyed on that same id.Operator messages and control mode
Three thread-scoped operator doors govern how a thread is answered: one sends an operator’s reply by hand, two read and set the thread’s control mode. All three carry the thread-belongs-to-route guard — a route-keyed id must carry the route’sbridge:{route_name}: prefix (400 otherwise), and a person thread
must be on the named route (404 otherwise).
The two write doors share the same write grant that creates a route, deletes
routes, and forgets threads — no per-thread owner check.
Control mode
A thread runs in one of two control modes.agent (the route default) runs the
target turn — the agent run or the tool dispatch — on each inbound message. manual
suppresses the target turn so an operator answers by hand; the platform’s own
control turns (pairing, the first-contact greeting) still run. The mode in force for a
thread is its per-thread override when one is set, else the route default: the route’s
initial_mode for a route-keyed thread, and for a linked person’s aggregated thread
manual when any route the person spans sets initial_mode: manual, else agent.
manual is valid on every target and door — initial_mode, the PUT …/thread/mode
door, and the set_conversation_mode builtin all take it, on a tool target or an agent
alike.
A manual-mode ordinary inbound is answered by nobody automatically: its target turn is
suppressed, so on a channel route the record ends terminal silent and on an api
route the silence is delivered as a status: "silent" marker. A pairing action and a
first-contact greeting are the exception — when a greeting is due it is prepended onto
the otherwise-silent outcome, so that record ends answered/delivered carrying the
greeting text.
Whether the suppressed inbound is remembered turns on the target’s memory. A target
that keeps thread memory — an agent that implements the thread-memory append — has the
inbound appended to that memory, so a later agent-mode turn reads it as prior context;
an append that fails is a loud error outcome, never a silent drop. A target with no
thread memory — a tool target, or an agent that does not implement the append — skips
the step: the transcript is the record, and nothing is lost because such a target never
had a memory to fall out of. The same conditional append governs an
operator send.
Read — GET …/thread/mode?thread_id= returns {mode, source}: source is
thread when a per-thread override is set, route when the answer is the route’s
initial_mode default. The thread id rides the query string, as the transcript door
takes it.
Set — PUT …/thread/mode takes {thread_id, mode} (mode one of agent/manual)
and returns {route_name, thread_id, mode, source} with source always thread — a
set writes an override. This is the door an external or programmatic caller names a
thread through; an agent flipping its own live conversation uses the
set_conversation_mode builtin instead, which reads the current thread from the turn
context rather than naming it. The override carries the answer
records’ answer_retention_ttl_seconds and is refreshed on thread activity — each
accepted inbound and each operator send — so it expires with the conversation: a
thread quiet past the retention window loses its override along with its records, and a
returning address starts again at the route default. A thread delete or a route delete
reclaims it at once.
Refusals (both mode doors): an unknown route 404, a blank thread_id 400, a
route-keyed id off the route 400, a person thread off the named route 404; set
adds a 400 for a mode outside agent/manual. Neither mode door takes an address.
Operator send
POST …/thread/messages sends an operator’s message by hand into a thread. Body:
{thread_id, text, address?, media?, template?, options?, schema?}; the answer is
{message_id, thread_id}. No turn runs: the record is minted already answered and
delivered through the same machine a produced answer takes — sent from the route
identity, with the same chunking, delivery ledger, and receipts. It is allowed in
either mode and never flips the mode.
media, template, options, and schema (an
ask-less form’s answer schema — the channel
renders text as the form’s prompt, and the participant’s submission enters the
conversation as an ordinary inbound message) are optional richer-send forms delivered
alongside text: the message is stored and delivered as one rich part, exactly
as a produced rich answer is, including the delivery machine’s capability gate — a
channel that does not advertise the matching supports_*_notifications flag never
receives the part; the record fails loudly instead of the field dropping. options
are the typed reply/link shapes
(a bare string is not an option); template carries its named components
(header_media, body_parameters, buttons). A contract-invalid value (an empty
list or dict, an over-cap value, or the mutually exclusive media+template /
options+template / schema+template / schema+options) is a loud 400; omit all four
for a plain text send.
The record is origin: "operator": it carries the text as its answer, an empty
inbound_text, never an inbound_form (an operator answers, it does not submit),
and names the sending operator in caller_principal. For a target that
keeps thread memory the text is appended to the thread’s checkpoint as an assistant
reply before the record is created — the same conditional append a
suppressed inbound takes, mirroring how the agent’s own answer enters memory — so a later
agent turn reads it as prior context; a target with no thread memory skips it. An append
that fails is a loud 500 and no record is created,
so the reply never stands in the transcript while absent from the memory a later turn
reads. The whole append-then-create runs under the thread’s per-thread FIFO — the
same lock in-flight turns take — so the send waits behind an in-flight turn on that
thread and never interleaves its writes; a full queue is a loud, retriable 503.
address picks the send target on a linked person’s aggregated thread: it must be
one of the person’s addresses (400 otherwise), and its route is the named route when
the address wrote under it, else that address’s own first route. With no address the
target is the thread’s newest record — its route and client address — so the reply
returns where the person last wrote from; an empty person thread with no address is a
400. On a route-keyed thread an explicit address must equal the address embedded in
the id.
Refusals: blank text 400, a present-but-blank address 400, the
thread-belongs-to-route guard’s 400/404, an unknown route 404, a full thread queue
503, no backend 501, and an unauthenticated caller 501 — an operator action
must be attributable, so access control must be enabled and the caller bound.
The delivery lifecycle
An answer record moves throughaccepted → pending_delivery → provisional →
delivered, or ends failed (undelivered past the attempt budget, or reported
failed by the provider), shed (refused by the address rate cap; no turn ran), or
silent (nothing sent back for the inbound: a channel tool turn whose mapped
reply was null/blank, or an ordinary inbound on a manual-mode thread whose suppressed
target turn produced no reply). An api turn that resolves silent does not end
silent: it rides the delivery pipeline like any answer and ends delivered, its
callback carrying status: "silent". Only the terminal states (delivered, failed,
shed, silent) carry the retention TTL.
Delivery is retried with exponential backoff; a provisional record awaits an
out-of-band receipt until delivery_grace_seconds elapses. A restart re-drives
every unfinished record, delivering exactly once (no double callback).
Client-facing text on the failure paths is always client-safe — an internal detail
never crosses to the participant. The turn-error reply is configurable per route; the
rate-cap notice is fixed:
- Turn error — the route’s own
error_reply_text(set with--error-reply-text) when it carries one, otherwise the built-in default"Sorry, something went wrong handling your message. Please try again."Anerroranswer never carries an internal detail; the detail is retained in the record’serrorfield for the admin read door only. - Rate-cap slow-down —
"You are sending messages faster than I can answer. Please wait a moment and try again."— sent once per cooldown window; further over-limit messages are dropped asshed.
Checkpoint retention
Conversation continuity is the checkpoint store’s. Idle-thread expiry is governed by the LLM checkpoint settings (theLLM_PROVIDER_* group), not a CONVERSATIONS_*
setting:
checkpoint_ttl_minutes is an idle TTL: a thread lives as long as it keeps
receiving messages and expires only after it sits idle past the lifetime. Unset
means threads are kept forever.
How the idle window is enforced depends on the provider:
rediscarries a native per-key TTL, refreshed on each checkpoint write — idle expiry is automatic.postgres/sqlitehave no native expiry; the sweep operation deletes every thread whose newest checkpoint is older than the cutoff.memoryis process-lifetime.
The
postgres checkpoint and store tables are self-managed by LangGraph: the
library creates and maintains its own schema. They are not part of a
migration chain and are outside
tai db migrate / tai db status, which cover only the framework’s and plugins’
own tables. There is nothing to migrate here — LangGraph sets its tables up on
first use.The sweep operation
sweep_checkpoints (POST /api/checkpoints/sweep, tai checkpoints sweep) is
admin-tier (a fenced, deployment-wide destructive memory purge). It deletes
stale threads on a DB-backed provider and is a reported no-op on redis/memory
or with the TTL unset. It runs on either recurrence path:
- Native schedules — recurrence needs the tool branched with the backend’s
schedule_taskextension: wrap the sweep in a small manifest tool carryingschedule_task, then schedule that branched tool viaPOST /api/schedules(tai schedules …) on a scheduler-capable backend. (Projecting as a tool makes the sweep runnable by name; theschedule_taskbranch is what registers a recurrence.) - External cron — call
tai checkpoints sweepon a timer from your own cron / systemd timer / k8s CronJob. The busless path — no scheduler backend needed.
The fire-path authorization model
A conversation route separates who may send from what the turn may do, and both are enforced live. Who may send. Achannel message is authenticated by the provider’s own
signature over the raw webhook body (fail-closed — a missing or bad signature is
rejected and runs no turn). An api message is authenticated by the access-control
gate on the send door. This mirrors the general
trigger authentication posture — a door is
public, verifier-signed, token-gated, or API-key-gated — applied to the two
conversation doors.
What the turn may do. The turn runs as the route’s bound execution_key,
never as the sender. That key’s live stored grants authorize the agent run (or
the tool target’s dispatch) and every tool call the turn makes — including any tool
a deep-agent sub-agent calls. The rules:
- Bind (pass-role). You may bind your own identity or an execution key you own;
an admin may bind any key. A non-admin’s refusal is a uniform
403across absent-key and not-yours, so the door is no probe for key ids. - Token-free-evaluable only. The bound key’s stored policy condition must be resolvable by a background execution with no request token. A condition that needs a request token is rejected at bind — it could never be evaluated at fire.
- Fingerprint binding. The route stores a server-derived per-mint
execution_key_fingerprint. The fire resolves the identity against that fingerprint, so a key re-minted under a new fingerprint no longer satisfies the route until it is re-bound. - Enforce at fire, automatic revocation. The turn is authorized against the key’s grants at fire time, not at bind. Attenuate the key by any means — drop a scope, disable it, delete it, narrow its owner — and the next turn is denied, with no revocation-specific step on the route. Scope the key (and the agent) to least privilege.
error outcome, its
detail retained for the admin read door.
Signed-callback posture. An api route’s callback_url is optional; when set it
must be absolute HTTPS — an http:// or relative URL is rejected at create, with
no insecure opt-out. A route with no callback_url records every answer for the poll
door (GET /api/conversations/{route_name}/messages/{message_id}) instead. Every callback
is signed: X-Tai-Signature: sha256=<HMAC-SHA256(callback_secret, raw_body)>, over a body of {message_id, thread_id, status, answer} — with answer
absent on a silent outcome. See connect your own
system.
Settings
Every setting reads theCONVERSATIONS_ env prefix (so max_concurrent_turns is
CONVERSATIONS_MAX_CONCURRENT_TURNS). Startup validators reject an invalid
combination loudly.
Backend
Turn-engine bounds
Delivery bounds
Retention / idempotency
See also
- Client conversations — the model.
- WhatsApp, Telegram / Slack, and your own system — the setup guides.
- Access control and owned keys — the execution key’s grants.

