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:
- The
target_name— the agent or tool the turn runs — must merely be registered, and so must a channel row’schannel. An unknown one is a400with one line per miss, for exampleroute target agent 'ghost' is not a registered agentorroute channel 'ghost' is not a registered channel. - The caller must be allowed to bind the route’s
execution_key(a pass-role decision). - The route write: the target’s bind check (a refusal is a
422carrying the refusal lines), then the route’s jq programs compile, then achannelrow’s(channel, our_identity)pair is stored canonical and must be unclaimed. The expression, identity and claim refusals are a400prefixedinvalid conversation route:— for exampleinvalid conversation route: invalid start_expr: …orinvalid conversation route: channel 'twilio' identity '+14155238886' is already routed by 'chat-line'. Last, a change ofdooron a route that holds threads is refused with a400of its own text, for exampleconversation route 'chat-line' holds 2 thread(s) opened on its 'api' door and cannot be changed to 'channel': delete the route ….
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’starget_kind chooses what an inbound turn runs:
agent(target_namea registered agent) — the default. The turn is the agent’sastreamon the route’s thread, so the conversation carries memory across messages (see conversation lifetime).tool(target_namea registered tool) — the turn dispatches the tool statelessly: no conversation memory, one message in and one reply out. It runs under the route’sexecution_key, whose grants authorize the dispatch.
start_expr— jq over the inbound payload{message, sender, our_identity, channel, route, turn}—routethe name of the route the turn entered through, so a target bound to several routes can tell them apart — carryingperson_idandperson_addressestoo when the target’s multichannel is on, andeventon an event turn — emitting exactly one JSON object: the started run’s kwargs (atooldispatch’s kwargs, or anagentrun’sastreamkwargs). Omitted, the kwargs are the fixed{message, sender, our_identity, channel, route, turn}for a tool (pluseventon an event turn), or the default{user_message}from the rendered turn text for an agent. Anagentstart_exprmust not set the route-owned run identity (thread_idor a checkpoint key), refused at create.person_idis the person the sending address resolves to andperson_addressesits address rows;person_idgives 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 ornull, 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_exprnames parked interactions to whole-chain kill (quietly: the channel releases its hold and nothing is said to the person),resume_exprnames an ask to resume with an answer ({id, payload}) or a waiting outcome to take (a bare id), andextras_exprbuilds theextrasmapping handed to the started target.
$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 noinput_schema keeps the
flow tool’s own flow_graph_kwargs argument, so the route maps the message into it:
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’soverlap 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
Underdeliver: 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}, plusform/attachments/location/reply_towhen that message carried them (reply_tois 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 throughsuccessor_id (the successor turn’s message_id):
merged— this message’s text was carried into the named turn (underdeliver: 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 acancelroute).
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 thesuccessor_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 arunning: 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 nocallback_url— read back fromGET /api/conversations/{route_name}/messages/{message_id}.200with the answer inline — await_secondsturn that finished in time; a route with a callback suppresses it so it never double-fires. A turn that finished silent returns200with the silent marker inline (status: "silent", no answer text).wait_secondsis clamped tosync_wait_max_seconds. 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 readsdeliveredat the poll door.- Refusals are loud:
400bad body,401unauthorized,404unknown route,429address rate cap,501no backend,503thread 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 typedreply_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 areason: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), orcould_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_largeorrejected_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;0disables 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.
The event door
POST /api/conversations/{route_name}/events (authed) delivers a structured event
into an existing thread as a turn. An event carries no human text: it runs the
route’s tool target through the same per-thread FIFO an inbound message takes — as a
turn, never a detached tool run — so it is ordered behind any
message turn already on the thread. It never mints a thread. On a linked-person thread the
event turn’s payload carries person_id and person_addresses exactly as a message turn
does — the existing person is resolved read-only, never provisioned.
Body (ConversationEventSubmission):
addressXORthread_id— exactly one, non-blank, names the target thread; both or neither is a400.event.event_id— the idempotency key: non-blank after trimming, ≤ 256 characters.event.kind— an identifier-like label matching^[A-Za-z0-9_.:-]{1,128}$(dotted and colon-segmented labels likeprovider.updateare admitted); it becomes the turn’sinbound.sourceasevent:<kind>.event.payload— an opaque JSON object, bounded as pure transport exactly like the message door’sform(a serialized-size and nesting cap); never validated against any schema.wait_seconds— the same bounded sync-wait window as the message door, clamped tosync_wait_max_seconds;0(the default) is an async202.
200with the answer inline — await_secondsturn on an api-door thread that finished in time; a route with a callback suppresses it so it never double-fires. 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 readsdeliveredat 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)orthread_idis refused, never opened).409— the route’s target is an agent: an event has no rendered text to hand an agent, so only atooltarget may run one.400— a bad body: theaddress/thread_idrule, a blank/over-longevent_id, a malformedkind, or an over-boundpayload.400— athread_idnaming 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:
401unauthorized,429address rate cap,501no backend,503thread queue full.
event_id, per route, in its own key family
(conversations:event-dedupe:{route_name}:{event_id}, held for inbound_dedupe_ttl_seconds
— never the channel dedupe namespace). A redelivered event_id starts no second turn
and returns the original turn’s message_id (a 202). A refused or failed admission
— a rate cap, a full queue — never burns the key, so a retry after one runs cleanly.
Thread addressing. The thread must already exist on the route; the platform recomposes
the id through the same function the route’s own door uses, never trusting a caller-composed
key. By address: a channel route composes the address exactly as its channel door
does; an api route qualifies the address by the calling principal (as the message door
does), so by address a caller reaches only its own threads on that route — another
principal’s api thread is reachable only by its listed thread_id. By thread_id: the id
the thread listing or transcript returns,
verified to be a member of the route’s threads.
Delivery follows the target route’s door: a channel route’s answer is sent to the
thread’s address; an api route’s answer is POSTed to the route’s callback_url, signed
with the route’s callback_secret — or returned inline on a bounded wait_seconds, or
recorded only when the route has no callback (retrievable through
GET /api/conversations/{route_name}/messages/{message_id}). There is no per-request
callback field: a caller-named sink would make the platform emit a validly signed POST to
an arbitrary host.
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.400 a non-integer, sub-1, or too-large window, or an unknown status; 401
unauthorized; 403 a non-admin caller; 404 an unknown route; 501 no conversations
backend.
Searching a route’s messages
GET /api/conversations/{route_name}/messages/search?q=…
(tai conversations search <route> --q …) returns every record on the route whose inbound
text or answer contains q, across all its threads, newest-active thread first. It is
admin-only (it spans every caller on the route) and each item is the whole record —
the same shape the transcript serves an admin. q is REQUIRED and non-blank.
The answer is
{items, total, page, page_size, next_page, truncated}. There is no per-route
record index, so the search is a bounded nested scan (the route’s threads, then each thread’s
records); a page that spends its budget answers truncated: true. Refusals: a blank/absent
q or a malformed window is 400; a non-admin is 403; an unknown route is 404; no
backend is 501.
The transcript
GET /api/conversations/{route_name}/transcript?thread_id=… reads one thread’s
records, one page at a time. The thread id rides the query string, not the path:
an API-door id carries a percent-encoded principal that no path spelling round-trips,
and a query value is decoded exactly once whatever it holds.
The answer is
{items, total, page, page_size, next_page, order, truncated}, its items the
same answer records the single-record door serves — whole records for an admin, the
caller-safe projection otherwise.
q is a bounded text search over the record content (the searched text lives inside the
record’s JSON blob), so a page that spends its scan budget answers truncated: true. Under
q, total is the number of matches found, and a thread that exists but matches nothing
reads as an empty page, never a 404 — the unknown-thread 404 below is reserved for a
thread the index does not hold at all.bridge:@person:{person_id} thread, or a channel
thread alike. Only the two listings stay admin-only (each spans every caller and
address on the route); a single thread read does not.
A 404 here is an addressing verdict, never an authorization one: an unknown
thread, an expired one, or a route_name that does not route at all. A blank
thread_id, an unknown order, or a malformed window is 400; a caller without the
read grant is 403; no backend is 501.
A thread the index still holds but whose records have expired under
answer_retention_ttl_seconds reads as an empty page carrying the indexed
total — not a 404 — until the prune pass reclaims the members.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 withorigin: "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’sthread-subject memory, across every route the person spans; - the person row
conversations:person:{person_id}; - every
person_indexdoor_address_key → person_idmapping 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
/linkkeeps the person created first and absorbs the other; a chain of links is followed to its end): each absorbed person’s ownbridge:@person:{id}thread on every route it wrote under, with its checkpoint, records and indexes, and itsperson-subject declared state, cleared before the person row goes.
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’sbridge:{route_name}: prefix (400 otherwise), and a person thread
must be on the named route (404 otherwise).
The two write doors share the same write grant that creates a route, deletes
routes, and forgets threads — no per-thread owner check.
Control mode
A thread runs in one of two control modes.agent (the route default) runs the
target turn — the agent run or the tool dispatch — on each inbound message. manual
suppresses the target turn so an operator answers by hand; the platform’s own
control turns (pairing, the first-contact greeting) still run. The mode in force for a
thread is its per-thread override when one is set, else the route default: the route’s
initial_mode for a route-keyed thread, and for a linked person’s aggregated thread
manual when any route the person spans sets initial_mode: manual, else agent.
manual is valid on every target and door — initial_mode, the PUT …/thread/mode
door, and the set_conversation_mode builtin all take it, on a tool target or an agent
alike.
A manual-mode ordinary inbound is answered by nobody automatically: its target turn is
suppressed, so on a channel route the record ends terminal silent and on an api
route the silence is delivered as a status: "silent" marker. A pairing action and a
first-contact greeting are the exception — when a greeting is due it is prepended onto
the otherwise-silent outcome, so that record ends answered/delivered carrying the
greeting text. 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 throughaccepted → pending_delivery → provisional →
delivered, or ends failed (undelivered past the attempt budget, or reported
failed by the provider), shed (refused by the address rate cap; no turn ran), or
silent (nothing sent back for the inbound: a channel tool turn whose mapped
reply was null/blank, or an ordinary inbound on a manual-mode thread whose suppressed
target turn produced no reply). An api turn that resolves silent does not end
silent: it rides the delivery pipeline like any answer and ends delivered, its
callback carrying status: "silent". 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:
- the target’s reply, when its failure carries one;
- the route’s own
reply_texts.error(set with--reply-error); - the built-in default
"Sorry, something went wrong handling your message. Please try again."
errorfield, for the admin read door only. A failure delivered when a parked run resumes keeps noerrordetail 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’sreply_texts.slow_downwhen set — sent once per cooldown window; further over-limit messages are dropped asshed.
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.
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.


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 (theLLM_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:
redisstamps every key a checkpoint write stores with the retention as its TTL, and a read never extends it — expiry is automatic.postgres/sqlitehave no native expiry; the sweep operation deletes every thread whose newest checkpoint is older than the cutoff.memoryis process-lifetime.
The
postgres checkpoint and store tables are self-managed by LangGraph: the
library creates and maintains its own schema. They are not part of a
migration chain and are outside
tai db migrate / tai db status, which cover only the framework’s and plugins’
own tables. There is nothing to migrate here — LangGraph sets its tables up on
first use.The sweep operation
sweep_checkpoints (POST /api/checkpoints/sweep, tai checkpoints sweep) is
admin-tier (a fenced, deployment-wide destructive memory purge). It deletes 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_taskextension: wrap the sweep in a small manifest tool carryingschedule_task, then schedule that branched tool viaPOST /api/schedules(tai schedules …) on a scheduler-capable backend. (Projecting as a tool makes the sweep runnable by name; theschedule_taskbranch is what registers a recurrence.) - External cron — call
tai checkpoints sweepon a timer from your own cron / systemd timer / k8s CronJob. The busless path — no scheduler backend needed.
The fire-path authorization model
A conversation route separates who may send from what the turn may do, and both are enforced live. Who may send. Achannel message is authenticated by the provider’s own
signature over the raw webhook body (fail-closed — a missing or bad signature is
rejected and runs no turn). An api message is authenticated by the access-control
gate on the send door. This mirrors the general
trigger authentication posture — a door is
public, verifier-signed, token-gated, or API-key-gated — applied to the two
conversation doors.
What the turn may do. The turn runs as the route’s bound execution_key,
never as the sender. That key’s live stored grants authorize the agent run (or
the tool target’s dispatch) and every tool call the turn makes — including any tool
a deep-agent sub-agent calls. The rules:
- Bind (pass-role). You may bind your own identity 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 uniform403across absent-key and not-yours, so the door is no probe for key ids. - Token-free-evaluable only. The bound key’s stored policy condition must be resolvable by a background execution with no request token. A condition that needs a request token is rejected at bind — it could never be evaluated at fire.
- Fingerprint binding. The route stores a server-derived per-mint
execution_key_fingerprint. The fire resolves the identity against that fingerprint, so a key re-minted under a new fingerprint no longer satisfies the route until it is re-bound. - Enforce at fire, automatic revocation. The turn is authorized against the key’s grants at fire time, not at bind. Attenuate the key by any means — drop a scope, disable it, delete it, narrow its owner — and the next turn is denied, with no revocation-specific step on the route. Scope the key (and the agent) to least privilege.
error outcome, its
detail retained for the admin read door.
Signed-callback posture. An api route’s callback_url is optional; when set it
must be absolute HTTPS — an http:// or relative URL is rejected at create, with
no insecure opt-out. A route with no callback_url records every answer for the poll
door (GET /api/conversations/{route_name}/messages/{message_id}) instead. Every callback
is signed: X-Tai-Signature: sha256=<HMAC-SHA256(callback_secret, raw_body)>, over a body of {message_id, thread_id, status, answer} — with answer
absent on a silent, 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 theCONVERSATIONS_ 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
- Client conversations — the model.
- WhatsApp, Telegram / Slack, and your own system — the setup guides.
- Access control and owned keys — the execution key’s grants.

