Skip to main content
Reference for the conversation bridge: the routing table, the read and delivery doors, checkpoint and answer retention, the fire-path authorization model, and every 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’s target_kind chooses what an inbound turn runs:
  • agent (target_name a registered agent) — the default. The turn is the agent’s astream on the route’s thread, so the conversation carries memory across messages (see conversation lifetime).
  • tool (target_name a registered tool) — the turn dispatches the tool statelessly: no conversation memory, one message in and one reply out. It runs under the route’s execution_key, whose grants authorize the dispatch.
A 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} — carrying person_id and person_addresses too when the target’s multichannel is on, and event on an event turn — emitting exactly one JSON object: the tool’s kwargs. Omitted, the kwargs are the fixed {message, sender, turn} (plus event on an event turn). person_id is the person the sending address resolves to and person_addresses its address rows; person_id gives 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 or null: the reply text. Omitted, the result must itself be a string or null.
The payload also carries a 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 takes flow_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 no callback_url — read back from GET /api/conversations/{route_name}/messages/{message_id}.
  • 200 with the answer inline — a wait_seconds turn that finished in time; a route with a callback suppresses it so it never double-fires. A turn that finished silent returns 200 with the silent marker inline (status: "silent", no answer text). wait_seconds is clamped to sync_wait_max_seconds.
  • Refusals are loud: 400 bad body, 401 unauthorized, 404 unknown route, 429 address rate cap, 501 no backend, 503 thread 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):
  • address XOR thread_id — exactly one, non-blank, names the target thread; both or neither is a 400.
  • 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 like provider.update are admitted); it becomes the turn’s inbound.source as event:<kind>.
  • event.payload — an opaque JSON object, bounded as pure transport exactly like the message door’s form (a serialized-size and nesting cap); never validated against any schema.
  • wait_seconds — the same bounded sync-wait window as the message door, clamped to sync_wait_max_seconds; 0 (the default) is an async 202.
Responses:
  • 200 with the answer inline — a wait_seconds turn 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) or thread_id is refused, never opened).
  • 409 — the route’s target is an agent: an event has no rendered text to hand an agent, so only a tool target may run one.
  • 400 — a bad body: the address/thread_id rule, a blank/over-long event_id, a malformed kind, or an over-bound payload.
  • Also loud: 401 unauthorized, 429 address rate cap, 501 no backend, 503 thread queue full.
Idempotency. The door is idempotent on 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 originclient 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.
Refusals: 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.
The transcript is a plain authed read gated by the conversations read grant — a holder reads any thread on the route, whichever door its messages arrived through: an API-door thread, a linked person’s 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_index door_address_key → person_id mapping of its addresses.
The person id rides the path — it is a uuid4 with no percent-encoded principal, so unlike a thread id it round-trips a path segment cleanly. Authority is the door’s write action — the same conversations write grant that forgets a thread. Erasing is idempotent and retryable. The person row is the durable marker of an owed erase and is deleted last (atomically with its index mappings), so an interruption re-reads it and a retry finishes. A person that is already gone — a retry, or one that never existed — is not a 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’s bridge:{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. ReadGET …/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. SetPUT …/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 through acceptedpending_deliveryprovisionaldelivered, 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." An error answer never carries an internal detail; the detail is retained in the record’s error field 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 as shed.

Checkpoint retention

Conversation continuity is the checkpoint store’s. Idle-thread expiry is governed by the LLM checkpoint settings (the LLM_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:
  • redis carries a native per-key TTL, refreshed on each checkpoint write — idle expiry is automatic.
  • postgres / sqlite have no native expiry; the sweep operation deletes every thread whose newest checkpoint is older than the cutoff.
  • memory is 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_task extension: wrap the sweep in a small manifest tool carrying schedule_task, then schedule that branched tool via POST /api/schedules (tai schedules …) on a scheduler-capable backend. (Projecting as a tool makes the sweep runnable by name; the schedule_task branch is what registers a recurrence.)
  • External cron — call tai checkpoints sweep on 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.
A public trigger-auth hook can be fired by anyone, with no credential — so the bound execution_key is the only thing bounding what a fired hook can do. Treat the key as the security boundary: bind a least-privilege key, and never bind an admin or broad key to a public (or token) hook — that lets any caller act with those privileges. Use verifier or api_key triggers when the fire itself must be authenticated.
Who may send. A channel 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 403 across 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.
A denied run does not crash the turn: it becomes a client-safe 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 the CONVERSATIONS_ 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