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 checks, in order, and a refusal at any step leaves an existing row untouched:
  1. The target_name — the agent or tool the turn runs — must merely be registered, and so must a channel row’s channel. An unknown one is a 400 with one line per miss, for example route target agent 'ghost' is not a registered agent or route channel 'ghost' is not a registered channel.
  2. The caller must be allowed to bind the route’s execution_key (a pass-role decision).
  3. The route write: the target’s bind check (a refusal is a 422 carrying the refusal lines), then the route’s jq programs compile, then a channel row’s (channel, our_identity) pair is stored canonical and must be unclaimed. The expression, identity and claim refusals are a 400 prefixed invalid conversation route: — for example invalid conversation route: invalid start_expr: … or invalid conversation route: channel 'twilio' identity '+14155238886' is already routed by 'chat-line'. Last, a change of door on a route that holds threads is refused with a 400 of its own text, for example conversation route 'chat-line' holds 2 thread(s) opened on its 'api' door and cannot be changed to 'channel': delete the route ….
An api row that declares a callback_url mints a callback_secret returned once in the create result and never re-readable. A backup restore runs the same route write (step 3) per row; the target’s existence is checked only at this door.
create flags: --door (api|channel), --target-kind (agent|tool, default agent), --target-name, --execution-key, the route’s jq programs --start-expr / --cancel-expr / --resume-expr / --extras-expr / --reply-expr (either target kind carries them), --initial-mode (agent|manual, default agent — the route’s default control mode), --channel / --identity (channel rows), --callback-url (api rows, optional, HTTPS), --overlap-running / --overlap-deliver / --overlap-settle-seconds (the overlap policy). --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.
Either target kind carries the route’s jq programs, all compiled at create:
  • start_expr — jq over the inbound payload {message, sender, our_identity, channel, route, turn} — route the name of the route the turn entered through, so a target bound to several routes can tell them apart — 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 started run’s kwargs (a tool dispatch’s kwargs, or an agent run’s astream kwargs). Omitted, the kwargs are the fixed {message, sender, our_identity, channel, route, turn} for a tool (plus event on an event turn), or the default {user_message} from the rendered turn text for an agent. An agent start_expr must not set the route-owned run identity (thread_id or a checkpoint key), refused at create. person_id is the person the sending address resolves to and person_addresses its address rows; person_id gives a target a channel-stable key for a linked person, one and the same across the channels they paired.
  • reply_expr — jq over the run’s result (the success shape), emitting the reply: null (silent), a string, or a list of answer parts. Omitted, a tool result must itself be a string or null, and an agent’s structured final is serialized to one string. It binds three variables: $turn (the turn ids, subject and reply window this reply answers), $asks (the run’s caller-ask entries when it asked its caller instead of finishing — an empty list on a plain result), and $parked.
  • cancel_expr / resume_expr / extras_expr — the door contract: cancel_expr names parked interactions to whole-chain kill (quietly: the channel releases its hold and nothing is said to the person), resume_expr names an ask to resume with an answer ({id, payload}) or a waiting outcome to take (a bare id), and extras_expr builds the extras mapping handed to the started target.
Every one of these reads the run’s currently parked interactions as $parked beside its own input. 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 start_expr over a form-less inbound sees a byte-identical payload, and the default no-expr kwargs stay the fixed {message, sender, our_identity, channel, route, turn} either way — a route maps the form deliberately or not at all. Without a start_expr an agent target never sees it — it reads the rendered text only; an agent start_expr may map .form into the run’s kwargs exactly as a tool route does. 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 target 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. The values are untrusted transport: the platform caps and delivers them but attaches no signature, expiry, or meaning, so a value a target must trust is a secret the target 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. Each door applies one declared policy to a violation: A refusing door answers a caller who is waiting; a dropping door’s vendor would redeliver a 5xx forever, so it never loses the participant’s message to a params bound. Every drop is logged at WARNING (naming the door and the bound or key, never a value) and counted on tai_entry_params_dropped_total{door, unit} on GET /metrics, where unit is set or value. The payload also carries a turn block on every turn — tool and agent targets alike, 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. turn.subject is the scope a state binding’s subject reads: the route’s target, the person id (null unless the target’s multichannel is on) and the resolved thread id, with the turn’s locale. turn.reply_window says whether a free-form reply can reach the client right now, as the channel reports it for the pair the turn replies on (our_identity, the client’s address): The window is read once per turn, when the turn starts; a reply delivered later (a parked tool resumed out of band) reads it again at delivery time. The platform only reports it and gates no send on it: a target uses it to choose between a free-form reply and a template, and the vendor’s own refusal of a free-form send outside the window (WhatsApp 131047, Twilio 63016) stays final and fails the delivery loudly. Which channels report a window, and what opens it, is on each plugin page. A read that fails (the channel’s store is unreachable) ends the turn as a client-safe error outcome. 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; its turn.reply_window is the window of the thread’s pair. With no start_expr a tool receives the fixed kwargs {message, sender, our_identity, channel, route, turn} (plus event on an event turn); a start_expr reads .turn and .event off the payload and forwards what it needs. The routed kwargs object — the fixed kwargs, or the start_expr result — is the target’s whole input. A plain tool, and a preset that declares no input_schema, receive it as their arguments. A preset that declares an input_schema validates it against that schema; with no input_map, it delivers the validated object under the argument its base tool’s input-schema support names (its payload_arg); with an input_map, the map builds the base tool’s arguments from it. With no start_expr, a conversation-target preset’s input_schema must therefore admit every default key (message, sender, our_identity, channel, route, turn, and event on an event turn): one that declares additionalProperties: false without them refuses the routed kwargs. 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 whose run fails is a client-safe error outcome instead. A failing target raises the contract’s RunTerminalFailed (a run’s error, stopped, or aborted terminal arrives this way), and reply_expr maps only a returned result, so a failure never reaches the mapping and goes straight to the failure reply: the target’s own reply when the failure carries one, else the route’s error reply.

Example: a Babelfish flow preset as a tool target

The Babelfish Flows plugin is one consumer of this door. It publishes a flow as a named tool (a preset). A flow preset with no input_schema keeps the flow tool’s own flow_graph_kwargs argument, so the route maps the message into it:
What a conversation-routed flow reads under flow_graph_kwargs is on the Babelfish page; // null keeps the turn silent when the result carries no answer. Such a tool turn need not answer synchronously: if it raises an async ask, 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.

Overlapping messages

By default every inbound message is its own turn, strictly ordered per thread: a second message that arrives while the first message’s turn is still running waits behind it and then runs its own turn. A route’s overlap policy changes that — it lets a running turn learn a newer message is waiting, cancel in favour of it, or carry a burst of messages into one turn. Three knobs on the route, set at create with --overlap-running / --overlap-deliver / --overlap-settle-seconds (or the overlap object on the API body): The default policy (continue / one / 0) runs one turn per message and leaves the payload unchanged — the same payload, records, and timing as a route that declares no overlap policy. A settle_seconds above 0 requires deliver: all or running: cancel; under continue + one a window would delay every turn for nothing and is refused loudly at create. The policy governs participant message turns only — records with origin: client and an inbound_kind of message. An event turn always runs as its own turn: it is never merged, never superseded, and never cancels a message turn. An operator send runs no turn, and a turn parked on an ask is not “running” — a reply while it is parked resumes through the answer ladder exactly as it does under the default policy.

The four combinations

Three messages arriving with the first turn already running:

The settle window

settle_seconds holds a turn back until that many seconds have passed since its lead message was accepted — a fixed window from the lead, never sliding, so the wait is bounded and a stream cannot starve it. A burst that lands inside the window rides one turn (under deliver: all) or is superseded by the newest (under cancel). The turn’s intake lease keeps refreshing through the wait, so the turn is never re-driven while it settles.

What cancellation can and cannot do

cancel is cooperative. A newer message sets a per-thread marker; a watcher armed around the running turn’s body cancels the owner task, and the turn stops at its next await — resolving superseded in favour of the newer message, with no reply and no delivery. It cannot interrupt a synchronous tool body already offloaded to a thread: that body runs to completion in the background and its result is dropped. Delivered messages and state writes are never reverted — a cancelled turn’s partial state stays in the state module and the next turn reads it as its starting document; reverting is the handler’s job. A tool body can cooperate where the watcher cannot reach: it reads the pending seam and yields early by raising TurnSupersededError, so an expensive or irreversible step never runs past a newer message. TurnSupersededError is a BaseException, not an Exception, so no broad except Exception between the yielding body and the platform’s turn runner — which catches it explicitly — ever swallows the yield.

The carried payload

Under deliver: one the tool payload is unchanged from the default. Under deliver: all (a tool target) the payload gains two keys:
  • messages — the batch in order, each {id, text, accepted_at}, plus form / attachments / location / reply_to when that message carried them (reply_to is the message it replied to).
  • superseded — present only when non-empty: the earlier messages dropped in favour of this turn, in the same shape.
message becomes the turn’s whole text: the superseded texts then the batch texts, in acceptance order, joined with a blank line — so a route with no start_expr and an agent target both see the whole turn with no new seam. A manual-mode thread appends the whole turn to its memory — the superseded texts then the batch texts, the same order as the joined message. The lead’s own top-level form / attachments / location / reply_to keys stay the lead’s. Map messages and superseded in a start_expr exactly as any other payload key.

The overlap outcomes

A message handed to another turn ends in one of two answerless terminal outcomes, each naming the turn that took its place through successor_id (the successor turn’s message_id):
  • merged — this message’s text was carried into the named turn (under deliver: all).
  • superseded — this message was dropped in favour of the named turn (a running turn cancelled by a newer message, or a queued message overtaken by a newer one on a cancel route).
The two doors deliver the outcome as they deliver silent:
  • Channel door — a terminal record, answerless; nothing is ever sent back.
  • API door — the record rides the delivery pipeline and is delivered as a marker: the callback (or sync-wait reply, or poll-door record) carries status: "merged" / "superseded" and the successor_id, no answer text, so the caller learns its message’s fate and where the answer will be.

The cancel marker and its setting

On a running: cancel route every participant message accept writes a per-thread marker in the conversations Redis — <prefix>:overlap:cancel:{thread_id} = {message_id, created_at}, TTL the thread lease — naming the newest message on the thread. The watcher a running turn arms reads it every overlap_cancel_poll_seconds and cancels the turn when the marker names a message newer than the turn’s batch. The marker lives in the shared Redis and the watcher runs in the turn’s holder, so cancellation works across workers with no local fast path. overlap_cancel_poll_seconds (default 0.5) must stay below thread_lease_seconds, or the marker could expire before the watcher next reads it. See Settings.

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, optional reply_to_message_id. 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. reply_to_message_id names the message this one replies to: the platform message_id of a message the platform delivered to the caller under it — the message_id on a 200 answer, in a signed callback body, or on a poll-door record. The platform resolves it against the same thread’s records into the text delivered under that id (a turn’s answer, an operator’s hand-sent message, an event turn’s answer) and hands it on as reply_to. An id that names nothing in this thread reads not_found; an id under which nothing with text was delivered — a silent, merged or superseded marker, an answer of media only, a turn not finished yet — reads no_text. The caller’s own submitted text is not what the id quotes. The value must be a UUID4; anything else is a 400.
  • 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. The inline answer is returned only while the caller is still connected; a caller that hung up is answered by the route’s signed callback, or on a poll-only route by the poll door. A hang-up in the instant the response is written still reads delivered at the poll door.
  • Refusals are loud: 400 bad body, 401 unauthorized, 404 unknown route, 429 address rate cap, 501 no backend, 503 thread queue full.
In-process face. A run inside the deployment sends to this door with the builtin send_conversation_message tool — no HTTP request and no route key handed to the run. The turn runs as the route’s execution key; the run’s execution identity is the accountable principal the door buckets on, and the same authorization the HTTP door applies is applied first. Its reply_to_message_id argument is the same field, under the same rules. The tool returns the accepted receipt at once — its answer is delivered by the route’s own configured delivery, never inline.

Replies to an earlier message

When a person’s message replies to an earlier message, the platform looks the earlier message up in the conversation’s own records and hands the turn a typed reply_to beside the text: the quoted message’s text, or the reason there is none. The text comes from the conversation record of the same thread, never from the medium, so it lives as long as that record does (the retention of the thread’s answer records) and is forgotten with it. Each medium marks a reply its own way; the channel passes on only the id of the message replied to: A button tap is a reply to the tapped message on WhatsApp, Telegram and Slack: a tap that answers a question quotes the question. Twilio sends no buttons, and a Slack modal submission names no message, so it quotes none. reply_to is {message_id, status, text}. message_id is the id the person’s side names the quoted message by — the medium’s message id on a channel, the platform message_id on the API door. text is set only when status is found: A quote is looked up only in the thread the reply arrives in, never under another address or caller. A person who links a second address writes in their bridge:@person:{person_id} thread from then on, while a message quoted from before the link stays in the route’s own thread, so it reads not_found (and the reverse after the person is erased). A deployment with no conversations store looks nothing up. A tool target reads the quote under its payload’s reply_to key, present only when the message replied to one; an agent target reads it through its start_expr. The record keeps it as inbound_reply_to, which the read doors serve. When the reply answers a pending question, the asker receives the same reply_to beside the unchanged answer, resolved in the question’s own thread (see Block or park: sync and async). The question’s send record keeps it as ask_reply.reply_to.

Inbound media

When a participant sends media (a photo, document, voice or audio clip, video, file, or sticker), the channel bridges it as a normal turn: the caption becomes the turn text, or, with no caption, a short non-blank placeholder such as [image], [document: report.pdf], or [voice message]. The media’s identity rides the turn’s params under a generic vocabulary — media_kind, media_id, media_mime_type, media_filename, media_sha256, media_voice, sticker_animated, and, where the channel reports it, media_size — where media_id is the reference the producing channel can later resolve. Each value obeys the standard per-value parameter cap; an over-cap value is dropped rather than truncated, and the turn still bridges. This vocabulary carries the media’s identity only: no bytes are fetched or stored for it. On the channels that ingest bytes, the bridged turn additionally carries typed attachments: the platform has fetched the file, verified it, and stored it by reference, and each attachment is a served reference — a same-origin capability URL (/api/interactions/media/{media_id}) a tool, an agent’s start_expr, or the operator console can render or download. The parity media_* params still ride the turn, now filled from the stored file’s real size, hash, and type. If the file cannot be received — it is over the per-kind cap, its type is not allowed, the byte source could not be fetched, or no storage provider is configured — no attachment is minted; the participant is told and the drop is recorded on the conversations_inbound_rejected event with a reason of too_large, unsupported_type, or could_not_receive. See the operations storage page for the ingestion requirements and the per-kind caps.

Inbound platform events

The inbound path emits two platform events a deployment can wire a hook to. Neither is a reply and neither returns an error to the provider — the inbound is always acknowledged so a provider does not redeliver in a loop; the event makes an otherwise-silent drop visible.
  • conversations_inbound_rejected — emitted when a channel receives content it recognises but cannot bridge as a turn, carrying a reason: unsupported_type (no mappable content, or the provider signalled a message type it will not deliver — for example a WhatsApp “unsupported message type” notice), too_large (a byte-ingested file over its per-kind cap), or could_not_receive (the byte source could not be fetched, or no storage provider is configured). The participant receives the route’s own notice for that reason when the route sets one (reply_texts.rejected_unsupported_type, rejected_too_large or rejected_could_not_receive), else one fixed generic notice — "This content type is not supported here.", "That file is too large to accept here.", or "That attachment could not be received." — and this event records the drop, so unsupported content is visible rather than silently discarded.
  • conversations_inbound_unrouted — emitted when a reply arrives for an address with no bound conversation route. It is distinct from a rejection: an unrouted reply and a recognised-but-unbridgeable content are different faults.

The working-on-it signal

While the platform works on a reply to a bridged inbound message it shows the channel’s own “working-on-it” indicator — the typing dots on WhatsApp and Telegram — and refreshes it under the vendor’s short expiry so it does not lapse mid-turn. The indicator stops the instant the reply is sent, when the record leaves the in-flight delivery states, or when the turn ends with no send; a hard ceiling bounds a turn that never sends. It is best-effort and cosmetic: a signal failure never reaches the turn. Two settings tune it (CONVERSATIONS_ prefix; see Settings for defaults):
  • CONVERSATIONS_WORKING_SIGNAL_MAX_SECONDS — the hard ceiling on one turn’s refresh loop; 0 disables the loop entirely (the kill switch).
  • CONVERSATIONS_WORKING_SIGNAL_REFRESH_MARGIN_SECONDS — the margin subtracted from the vendor indicator’s lifetime to choose the refresh interval, so each refresh lands before it lapses.
Not every channel drives it: Slack and Twilio show none (their API offers no bot typing indicator), and the web chat page shows its own client-side typing indicator rather than a server-driven refresh.

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. The inline answer is returned only while the caller is still connected; a caller that hung up is answered by the route’s signed callback, or on a poll-only route by the poll door. A hang-up in the instant the response is written still reads delivered at the poll door.
  • 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.
  • 400 — a thread_id naming a linked person’s thread whose records are all send records: thread <id> holds no message from the participant to address the event to; submit the event by address. On a linked person’s thread the event goes where the person last wrote from, and a send record does not count as writing.
  • 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.
In-process face. A run delivers an event to this door with the builtin send_conversation_event tool — no HTTP request, no route key. It runs the route’s tool target as the route’s execution key, applies the same authorization the HTTP event door applies, addresses the existing thread by thread_id or address, and returns the accepted receipt; the answer rides the target route’s own delivery. Like the HTTP door it is idempotent on event.event_id.
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, and the delivery bookkeeping such as pending_receipt); 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, send for a message the platform sent to a participant on its own (see send records) — 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 transcript page carries person_id beside its records: the linked person a bridge:@person:{person_id} thread aggregates, null for a route-keyed thread. A thread that answers the uniform 404 carries none. Unreadable rows are counted, not hidden. Every listing — the thread listing, message search, the transcript, and the route and config listings — returns an unreadable count beside its rows. A stored row the platform cannot decode, or whose row has gone from under its index, is tallied there and omitted from the returned items, so a shorter-than-expected listing carries a nonzero unreadable you can act on rather than a silent gap. A record whose route was deleted still stands. A conversation record accepted while its route was being removed is kept and delivered, and carries route_missing: true in both the admin and caller-safe reads. Because its thread indexes were never written, no transcript names it — the flag is how you recognise such an orphaned-route record rather than assuming it was lost, and the platform logs the orphaned record loudly when it is created. A send record that completes or ends while its route is being removed is kept the same way, with route_missing: true, and no transcript names it. 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: A send record counts like any other record: a thread a send started is listed, its message_count includes the send, and when the newest record is a send its address and delivery state are the ones shown. status is validated against the delivery-status vocabulary (accepted, pending_delivery, sending, provisional, delivered, failed, outcome_unknown, shed, silent, merged, superseded); outcome_unknown can match a thread, while sending never matches one, because a send still going out is not in any thread yet. 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.

Send records

Every message the platform sends to a participant on a channel with a bridge route is recorded in that participant’s thread, as a record with origin: "send": a notify_user send, a question an ask delivers on a channel, the platform’s own notices (a refusal, an ask retry or closed notice, a rejected-inbound notice), and a message a channel plugin sends on its own. The record carries the text in answer and an empty inbound_text. A send on a channel with no bridge route, or on a deployment with no conversations store, still goes out and is not recorded. A send lands in the thread an inbound message from the same participant would use, and may start it; a later message from them continues it. A completed send is placed by the address the medium reports (so a Telegram send to an @username lands in the thread of its numeric chat), a send that never completed by the address resolved before sending. A send made during a turn to that turn’s participant stays in the turn’s thread. Only a reply that came over the channel is recorded in ask_reply. For a question asked as sensitive the record keeps that the reply came, when, and its message id, never its body: answer is null in every view, and the transcript, message search and a quote of that reply show no body. An answer given through the answer door, or a caller resume, writes nothing to the record: it keeps the delivery state its send reached and has no ask_reply. Message search (q) also matches the text of an ask_reply answer. A participant record carries inbound_reply_to: the message it quoted as {message_id, status, text}, or null. Both the admin and the caller-safe view serve it. Operator, send and event records never carry one.

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, its thread indexes, and the thread’s declared-state records — so a later message on the same address starts a memory the deleted turns never touched. It is a complete forget: the transcript and the conversation’s own declared-state memory both go, not just the transcript. A linked person’s aggregated bridge:@person:{person_id} thread is forgotten across every route index it spans, including a route one of the person’s addresses was later unlinked from and a route a send to the person used. 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 retained memory is not left behind once the records lapse. The forgotten records include the thread’s send records and their ask replies. Each record’s message-refs, outbound receipt index entries and ask index entry are removed in the same step as the record, so a later quote of one of its messages resolves to nothing, and a delivery receipt for one of them reaches only the run trace. A reply over the channel to a question of the forgotten thread is not recorded (a WARNING is logged when the forget lands while the reply is being written). A send still going out when the thread is forgotten is forgotten with it when it ends in that thread: the message may still reach the participant, but no record of it is kept, the thread is not re-created, and a WARNING names the record. A send whose medium answer places it in another thread (a handle resolved to a numeric chat id) is recorded in that thread. A quote’s text lives only on the records this deletes, so it is forgotten with them. A parked ask in the forgotten thread is withdrawn before any record is deleted, so no continuation runs with a quote; a synchronous ask still waiting reads not_found for a quote into the forgotten thread. 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, states_erased, route_name, thread_id}, where removed counts the answer records cleared and states_erased counts the thread-subject declared-state records this call forgot across the routes the thread spans — 0 when the states feature is off or the thread held no declared state. The reset clears the thread’s own thread-subject state only — the conversation’s memory keyed on this thread; declared state a binding scoped to the person (deliberate cross-thread memory) is cleared by forgetting the person, not a single-thread reset. State erasure runs last under the same in-flight/FIFO guard, so a live turn can never race it; a failure there raises loudly and the idempotent call is retried. 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 (including a route one of its addresses was later unlinked from, and a route a send to the person used) its answer records, per-thread transcript indexes, route-thread index memberships and per-thread mode override (the thread-forget machinery, reused);
  • its declared-state records — both the person’s own person-subject memory (the cross-thread memory a binding scoped to the person) and the aggregated thread’s thread-subject memory, across every route the person spans;
  • the person row conversations:person:{person_id};
  • every person_index door_address_key → person_id mapping of its addresses;
  • every thread only this person and the persons it absorbed wrote in, on every route: the chat each of its addresses had on its own channel, before and after a link, written by a message or by a send alone, including on a route deleted or moved to another target since, each with its checkpoint, records and indexes and its thread-subject declared state. On a route deleted before the erase, that thread’s records are left to their retention period, as the route delete leaves them; its checkpoint and state are cleared.
  • every person it absorbed by a link (a /link keeps the person created first and absorbs the other; a chain of links is followed to its end): each absorbed person’s own bridge:@person:{id} thread on every route it wrote under, with its checkpoint, records and indexes, and its person-subject declared state, cleared before the person row goes.
A thread another person also wrote in is left to them: a chat someone else wrote in before the link, or continued after it (an address unlinked from the person and written on by whoever holds it now, or a route edited or created again under its name and written on under its new target or number). It keeps its messages until their retention period, is not counted in removed, and a turn in flight in it is no 409: the erase waits for the turn before leaving the thread, up to the same bound as for a thread it clears. It is cleared once every person who wrote in it has been erased, or by the erase of a person who absorbed them all, even when their erases run at the same time. The person’s own replies and sends that land in a thread after a link are cleared with that thread. A message that lands in a thread while the erase is clearing it is cleared with it, whoever sent it, as for a thread deleted while a message arrives. A message to or from one of the person’s addresses that lands in a thread after that thread was cleared (by this erase, by the erase of the other person in a chat it left to them, or by a thread delete) is kept: it starts that thread again, and it is cleared by the erase of whoever next holds that address, whether or not they write in that thread, by a thread delete, or by the erase of another person who wrote in that chat before. One that lands in a chat left to another person who still writes there stays with that chat and is cleared by that person’s erase. When an erase stops part-way with an error and someone else writes in a thread it was clearing before the retry, the retry leaves that thread to them. An erase that fails does not bring back what it already cleared, and that includes a chat shared with another person that this person’s erase was leaving to them: if that person’s own erase cleared the chat while the failed erase ran, it stays cleared. A message from one of the person’s addresses that starts a thread on another route in the last moment of the erase makes the erase answer 409 (“a write by … in the thread … landed while the erase of … ran; retry the erase, which clears that thread too”); the person stays marked as being erased, and a retry finishes the erase and clears that thread too. A send still going out to the person when it is erased is forgotten with it, as for a forgotten thread. 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. A quote’s text lives only on the records the erase deletes, so it is forgotten with them. A parked ask in an erased thread is withdrawn before any record is deleted, so no continuation runs with a quote; a synchronous ask still waiting reads not_found for a quote into an erased thread. The answer is {person_id, removed, states_erased, erased, absorbed_person_ids}, where removed counts the answer records deleted across the person’s routes, states_erased counts the declared-state records this call forgot — the person’s own (person-subject) and its aggregated thread’s (thread-subject) memory — and erased says whether this call removed the person row. removed and states_erased also count what the erase cleared for the persons it absorbed, and absorbed_person_ids lists their ids. A turn in flight in one of their threads is a 409 like one in the person’s own thread, and nothing of the person itself is removed then. 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); an id a /link absorbed into another person is a 409 naming the person it was absorbed into, in its message and in absorbed_into_person_id — that person’s erase is the one that clears it, and it removes that person too; the refusal touches nothing; a blank person_id is a 400; no backend is a loud 501. While a person is being erased. Once the erase has started, nothing new enters the person’s thread. A message sent to or from one of the person’s addresses during the erase goes to that address’s own thread on its route, as after the erase. When the erase clears that thread, a message landing in it before the erase reaches it is cleared with it, and one after starts a fresh thread. A message already on its way to the person thread is not recorded there: a send’s record is forgotten with a WARNING; an inbound is refused with a retriable 503 (or an error to the channel) and lands in the address’s own thread when sent again. Meanwhile every read of the person answers as if it were gone (the person read and its thread are 404s). A link, an unlink or a locale change changes nothing and is refused with a retriable 503. A /link, a pairing code or an /unlink sent from one of the person’s own addresses reads the route’s error text (reply_texts.error, by default “Sorry, something went wrong handling your message. Please try again.”), and its code stays valid. A code the person minted, redeemed from another person’s conversation, reads the link-unavailable reply (reply_texts.pairing_link_unavailable) and is used up. A message from one of its addresses is answered with the route’s error text; sent again after the erase, it starts as a new person in that address’s own thread on its route (a fresh thread when the erase cleared it). No declared state can be written for the person; a write already under way when the erase starts is finished and then forgotten with the rest. Nothing written for the person outlives the erase, and no other person’s addresses or state are touched. An erase that stopped part way leaves the person treated as unlinked until it is run again, which finishes it.
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. The traces of a person the erased person absorbed by a link carry that person’s own id as their user_id, and the answer’s absorbed_person_ids lists those ids, so delete the traces keyed on the erased id and on each listed id. The interaction store is not erased either: an answered question keeps the request’s recipient, and a non-sensitive answer, until its idle TTL (INTERACTIONS_IDLE_TTL_SECONDS). Nor is the hooks store: a hook run held as failed is kept with no TTL until an operator retries or discards it, and its event payload can carry the person’s address. Discard such runs with tai hooks runs discard.

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. A greeting owed on a turn that is then superseded — cancelled by a newer message or yielded by the turn body — is never dropped: it is carried to the successor turn and leads that turn’s reply, on every door. 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 that is not a send record — its route and client address — so the reply returns where the person last wrote from (or where an operator last sent); a person thread with no such record and 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 accepted → 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". A message handed to another turn under an overlap policy ends merged (its text carried into the named turn) or superseded (dropped in favour of one) — both answerless terminal outcomes naming that turn in successor_id, and on the api door delivered as a marker exactly as silent is. Only the terminal states (delivered, failed, shed, silent, merged, superseded) 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). A send record has its own path. It is written sending before the message goes out, then moves to provisional when the medium accepts it (and on to delivered by a receipt or the grace period, as an answer does), failed when the send raised, or outcome_unknown when nobody can tell whether the message went out: the send passed its bound, it was cancelled while the medium call was in flight, an ask attempt timed out and no later one succeeded, or the process stopped after the sending write. A send record is never re-sent: an outcome_unknown record keeps its error, a WARNING names it (the texts are in Send outcomes and delivery receipts), and a send that passed its bound raises a non-retryable error, so no caller retry sends it twice. A sending row the process left behind is settled to outcome_unknown by the sweep or the boot redrive once its settle deadline passes. An ask whose attempt timed out before a later attempt succeeded completes from the later attempt, with ask.timed_out_attempts naming the earlier ones and a WARNING that the participant may have received the question more than once. A reply over the channel moves an ask’s record to delivered, whatever its delivery state was. sends holds one entry per part the medium accepted. The write comes first: a send whose sending write fails is not made, and the store error is raised (a platform notice logs it at ERROR instead). A send the medium accepted whose completion write then fails is logged at ERROR with its message ids and returned to the caller, never raised, so it is not sent twice; its row is later settled as outcome_unknown. A receipt that arrives before the send finishes is held, not lost. A channel can deliver a delivery receipt (a status callback) for an outbound message before the platform has finished recording that send. When it does, the receipt is parked on the record — the callback is acknowledged normally, so the provider does not redeliver in a loop — and applied the moment the send completes, moving the record straight to its delivered or failed outcome. If the send itself reaches a failure terminal first, the sender’s verdict wins and the parked receipt is dropped with a logged warning. An in-flight record holding a receipt shows it in the admin record view under pending_receipt. When a reachable channel terminally refuses a turn’s reply — an answer over the channel’s cap, an unrenderable part, a permanent input refusal, or a non-retryable send rejection — the record stays failed and the participant is sent the platform’s fixed could-not-deliver notice (the route’s reply_texts.error when set, else the built-in default); a transport exhaustion — retryable failures that spend every attempt — stays failed with no such notice, the failed-delivery listing being the only signal. A pending or provisional delivery row that can never be parsed is moved to the terminal failed state — it stops being retried on every sweep and appears on the failed-delivery listing, where an operator can see and clear it. For those delivery-lifecycle rows the unreadable count a listing reports and this terminal failed state are the same fact seen twice: the row a read tallies as unreadable is the row the sweep fails, not a second one. A corrupt row counted unreadable on any other listing (a thread, transcript, route, or config listing) has no failed twin — the sweep only transitions delivery-lifecycle rows, so those counts stand on their own.

Failure replies

The platform never puts an internal detail into the text a participant reads. Its own failure text is fixed and client-safe; a target that fails may supply a reply of its own, and the platform sends that reply exactly as the target wrote it.
  • Turn error — when a turn fails, the participant reads the first of:
    1. the target’s reply, when its failure carries one;
    2. the route’s own reply_texts.error (set with --reply-error);
    3. the built-in default "Sorry, something went wrong handling your message. Please try again."
    On a live turn the internal detail is kept in the record’s error field, for the admin read door only. A failure delivered when a parked run resumes keeps no error detail on its record.
  • Rate-cap slow-down — "You are sending messages faster than I can answer. Please wait a moment and try again." — or the route’s reply_texts.slow_down when set — sent once per cooldown window; further over-limit messages are dropped as shed.
A target supplies its reply on the failure it reports. A tool raises RunTerminalFailed(outcome, reply="..."); a parked run that fails on resume raises the same, or returns RunFailed(outcome=..., reply="..."). A Babelfish flow, for example, supplies one through its flow-level On Error’s Reply. The reply follows the same rule as every reply_texts member: a string of 1 to 2,000 characters that is not blank. An invalid reply is refused where the target raises or returns it, and a reply that reaches a completion delivery malformed is refused loudly there, with no record written. The target’s reply covers only the target’s own failure. Every failure the platform owns keeps the fixed text above: an infrastructure error or any other exception raised in place of a reported failure (a timeout among them), a door contract or reply_expr fault, a denied run, a stranded turn, a channel refusal notice, and a give-up (an abandoned resume, or a preset’s output map refusing a resumed answer) with no handler result. A transport exhaustion sends no notice at all.
A target’s reply is shown to the participant as written. Its author owns that it is safe for the participant and carries no internal detail, just as a route admin owns the route’s reply_texts.

The route’s reply texts

A route may set its own words for every reply the platform sends on its own, in the line’s language. reply_texts is an optional object; each member is optional, and a member left unset sends the built-in English text. An empty object, an unknown member, or a blank or over-long member (each 1 to 2,000 characters) is refused at the route write. pairing_code is a template: it must contain {pairing_code} (the code) and may contain {expires_at} (the code’s ISO-8601 expiry), with no other field, conversion or format spec. Every other member is literal text: a { in it is sent as written.
In the Studio the route dialog carries the same members as its Reply texts group; a field left blank sends the built-in English reply.
The Reply texts group of the route dialog, one input per reply, with Failed turn and Pairing code filled in and the rest blank.The Reply texts group of the route dialog, one input per reply, with Failed turn and Pairing code filled in and the rest blank.

The route dialog's Reply texts group, with the failed-turn and pairing-code replies set.

Checkpoint retention

Conversation continuity is the checkpoint store’s. Thread expiry is governed by the LLM checkpoint settings (the LLM_PROVIDER_* group), not a CONVERSATIONS_* setting: A conversation thread is never marked finished — it stays open for its next turn — so it lives the waiting retention after its last write: a thread that keeps receiving messages keeps living, and one that sits idle past the retention expires. A turn’s agent that checkpoints on the thread takes it out of the store’s finished set when its run starts; an agent that keeps no checkpoints touches no checkpoint store. How the window is enforced depends on the provider:
  • redis stamps every key a checkpoint write stores with the retention as its TTL, and a read never extends it — 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.
See Checkpoints and retention for both horizons, the threads other owners mark finished, and the live-thread filters that spare a thread a parked run still needs.
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 finished threads past the finished retention on every provider and, on a DB-backed provider, every thread past the waiting retention; a thread a live-thread filter claims (a live park) is spared and listed. See the checkpoint sweep for its result. 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 when you are an api key, or an execution key you own; an admin may bind any key. An account identity is refused with a 400, because a route runs as an api 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, merged, or superseded outcome, and a successor_id present on merged/superseded naming the turn that took the message’s place. 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. The CONVERSATIONS_ prefix is owned: an env name under it that names no setting below is refused at boot and on every env write.

Backend

Turn-engine bounds

A turn waiting for a busy thread starts when the running turn releases the thread (a notification), or when a crashed holder’s lease expires. The notification is Redis pub/sub on the conversations Redis: a release publishes the thread id on the channel <prefix>:thread_lease_released, and a waiting turn subscribes to it. When the conversations Redis user is a restricted ACL user (Redis 7 creates new ACL users with resetchannels), grant it the PUBLISH and SUBSCRIBE commands and that channel (for example +publish +subscribe &conversations:thread_lease_released with the default prefix). Without them every release logs conversations: releasing thread lease … failed; its TTL is the hard bound and every turn that has to wait for a busy thread fails on its subscribe.

Delivery bounds

A send record’s settle deadline is the time it was begun, plus the send’s bound (delivery_send_timeout_seconds, or for an ask what is left of its window), plus delivery_claim_lease_seconds. A row still sending past it is settled to outcome_unknown within delivery_sweep_interval_seconds.

Retention / idempotency

Message-refs. Each record also keeps a message-ref per medium message it carries, conversations:message_ref:{sha256}, keyed on the channel, the route identity, the participant address and the medium’s own message id. It maps that id to the ordered list of records that carry it: one medium message can be several records (a Slack message with several files, or a Twilio message with several media items, is one record per item, all under the same id). This is how a quote of a message resolves to its record. An inbound record keeps two ids. The dedupe id (provider_message_id) is what makes a replay start no second turn, and can be per item ({event_id}-{index} on Slack, {MessageSid}-{index} on Twilio). medium_message_id is the medium’s own id for the message itself, the one a later quote names; each channel’s plugin page says which id it passes.

See also