The two doors
A message reaches the bridge through one of two doors, chosen per route:channel— a messaging provider’s webhook delivers an inbound message. The route is matched by the(channel, our_identity)pair — the registry name of an installed channel (seetai channels list) and the medium address the client texted (a phone number, a chat, a Slack conversation). The answer is delivered back through the same channel’s adapter, sent from the identity the client texted.api— an authed callerPOSTs to/api/conversations/{route_name}/messages. The answer is delivered by a signed HTTPS callback to the route’scallback_urlwhen it declares one, returned inline on a bounded sync wait, or read back from the poll door when it declares none.
api door
answers 400, and a channel drops it (logged, nothing sent back).
An authed integration can also deliver a structured event into an existing thread
through the event door: the event
enters that thread as a turn, ordered behind any in-flight message on the same
per-thread FIFO an inbound message takes, and its answer is delivered by the target
route’s own door. An event carries no human text — it runs a tool target only — and
never mints a thread; it always joins one that already exists.
The routing table
A route is one row in the conversation routing table. It binds an inbound door to what a turn runs — an agent or a tool — and the execution key that turn runs as:
A
channel route’s (channel, our_identity) pair is unique — a second route
claiming the same identity is refused at write, so every inbound message resolves
to exactly one route. Many rows may share one channel, each with its own identity,
so one provider account fans out to many targets.
Manage the table from the Studio Conversations screen,
the tai conversations CLI, or the /api/conversations routes; see the
bridge reference.
Agent and tool targets
Atarget_kind of agent runs a registered agent as the turn: it streams on the
route’s thread, so the conversation carries memory across messages. A target_kind
of tool dispatches a registered tool statelessly — no conversation memory,
one message in and one reply out. Either kind carries the route’s optional jq
programs shaping the call:
start_exprmaps the inbound payload{message, sender, our_identity, channel, route, turn}to the run’s kwargs — a tool’s kwargs, or an agent’sastreamkwargs; without it a tool gets those six keys (pluseventon an event turn) and an agent reads the rendered turn text.reply_exprmaps the run’s result to the reply text; without it a tool result must itself be a string ornull. It only ever sees a returned result: a run that fails (a run’serror,stopped, orabortedterminal among them) skips the mapping and resolves as theerroroutcome instead, whose text is the target’s reply when the failure carries one, else the route’s error reply (failure replies). It also binds$turn,$asks, and$parked(see jq variables).cancel_expr/resume_expr/extras_expr— the door contract driving the run’s parked interactions, each reading$parked.
params object when the inbound entry supplied one —
a web-channel visitor’s link parameters,
the API message door’s params field, or a channel’s tap enrichment when a person
taps a control the target offered: the tapped reply’s author-set id arrives as
params.reply_id (a sectioned row’s description as params.reply_description where
the medium carries it), a template quick-reply as params.button_payload, a
click-to-chat entry as its referral_* fields. All are string values reachable as
.params.<name>, present only when non-empty. They are untrusted transport the
platform caps and delivers but never signs or interprets; a value a target must trust is
a secret the target checks in its own store.
This is the bridge-path counterpart of the enrichment an
answer carries: when a tap
answers a pending ask its context rides the answer’s params, and when the
same tap is instead bridged as a fresh turn (no pending ask, or a bridge-policy
digression) it rides this payload params — the same enrichment on either arm, never
dropped.
A tool reply that is null or blank is a deliberate silence — the turn ran but
has nothing to say. On a channel route nothing is sent back (a terminal silent
record); on an api route the silence is communicated, delivered as a callback (or
sync-wait reply, or a poll-door record) whose status is silent and which carries no answer text. That
makes a tool target a way to answer some messages and stay quiet on others: a
published preset, say, that replies only when it has
something to return. See the bridge
reference for the exprs
in full.
A tool target may also carry a
state_binding, so the route injects a bound
state’s input values into the dispatch and applies its update afterward — a binding applies
to tool targets (a registered preset or a plain tool). An agent target takes no binding: a
route with target_kind: agent that carries a state_binding is refused at save (400).
A tool target need not answer in one synchronous step either. Like an agent
target, its turn can async-park on an ask — pausing for a person’s answer or
its expiry — and deliver its resumed reply back into the originating thread later,
mapped through the route’s reply mapping.
A run may also ask its caller (to="caller") rather
than a person. When a turn asks its caller instead of finishing, its reply_expr sees
those asks in $asks and maps them to the reply, and the route’s resume_expr
resolves them on the next visit — the door
contract in full.
Structured participant messages
A participant message is always text first. When an inbound message carries a structured object — an ask-less form’s submission — the text is a faithful rendering of the submission and stays the whole turn every consumer sees: anagent target reads the text only, the transcript
records the text, and a form-unaware route behaves exactly as before. The structured
copy rides beside the text: a tool target’s payload carries it under a form
key, present only when the inbound carried one, so an existing start_expr
sees a byte-identical payload for a form-less inbound — a route maps the form
deliberately or not at all. The answer record keeps the object as inbound_form,
published on the admin and caller-safe reads alike.
The values are untrusted participant data: the platform bounds them as pure transport
(a serialized-size and nesting cap) and never validates them against the form’s
schema — a submission is exactly as trustworthy as typed participant text, so a tool that
consumes one validates what it reads. The API message door
accepts the same shape: a form object beside the required text, under the same
bounds.
An inbound message may carry three more structured siblings the same way — beside
the text-first turn, delivered to a tool target’s payload only when present:
attachments— a list ofMediaItems the person sent with the text (image/document/video/audio), the inbound counterpart of an outbound answer’smedia. It rides under anattachmentspayload key.location— a geographic point the person shared, aLocationElement(latitude/longitude with an optional name/address). It rides under alocationpayload key.reply_to— the earlier message this one replied to, as the platform resolved it from the conversation’s own records:{message_id, status, text}, the quoted text when it is held. It rides under areply_topayload key. See replies to an earlier message.
form pattern exactly: the text stays the whole turn every reader
(and every agent target) consumes, the structured copy is delivered only to a
form-/media-aware tool route, and a form-less, attachment-less, location-less
inbound that quotes nothing is byte-identical to the pre-vocabulary payload. The values are the same
untrusted, transport-bounded participant data. A quote’s text is the platform’s own
record of the earlier message, not text the person sent with this one.
On some channels an inbound attachment bridges as params rather than a typed
attachments entry — WhatsApp, for one, forwards an inbound media message with its
identity under params.media_kind / params.media_id (the Graph media id is not a
durable served-media source, so a consumer re-fetches the bytes with operator
credentials). An inbound location on that channel does land as a typed
location. What each channel bridges is documented on its
plugin page.The person’s locale
A conversation carries a locale — a BCP 47 language tag (e.g.he-IL) the platform’s
rendering layer resolves every template and
list format against. The caller states the language; the target never selects one, and a null
locale means none is known (never a silent default).
ConversationMessage.locale— the participant’s per-message language hint the channel resolved from its native inbound, carried beside the text (and accepted on the API message door). It seeds a first-contact person’s stored locale and, absent a stored override, is the turn’s resolved locale.- the person’s stored
locale— the operator override held on thePersonrow, seeded from the channel at first contact and canonical in storage. Set or clear it throughPUT /api/conversations/persons/{person_id}/locale(the same write grant that forgets a thread) or the CLI’stai conversations set-person-locale/ read it withtai conversations get-person. A later turn never clobbers a stored override. - the subject
locale— the locale captured onto each turn’s subject, resolved by precedence: the person’s stored locale (operator override or first-contact seed) wins, else the message’s per-message hint, else the route’s defaultlocale(--locale), elseNone. It is what the renderer reads for every template and thelist_formatfilter.
503, and nothing changes.
Rich, multi-message answers
Atool target need not answer with a single string. Its reply_expr (or, with no
expr, the tool’s own result) may emit an ordered array of parts — each element
either a plain string (a text-only message) or an object carrying message with the
same rich forms a notification
carries: media (image/document/video/audio files with an optional document
filename, plus links), a shared location, tappable options, a sectioned
sections list, a media header and text footer, a pre-approved template, or an
ask-less form’s schema. A form part may also
carry the same per-send data and pages
an ask does — data.values prefills top-level properties, data.options supplies a per-send
choice list, and pages lays the form out in titled steps — so a reply part can open its form
already filled in and stepped. data/pages ride ONLY a form part (a part carrying schema);
on any other part they are refused, and on a form part each is cross-checked against that part’s
schema before delivery. The delivery
machine sends each part as its own message in order, so one turn can reply with
several bubbles, attach a document, share a pin, or offer tappable options. A part may
be content-only — an object with media or location and no text — for a
caption-less image or a bare pin. The array is strict: an unknown key or an empty
array is a loud error, never silently coerced, and each part obeys the same
composition rules (options
XOR sections; schema excludes both; header/footer require a choice surface;
template stands alone) and the same per-channel
capability gate — a channel
without the matching capability never receives the field. Each part is also held to
the same channel checks a notification gets — a template part to the channel’s own
template-parameter check, a schema part to the channel-renderable subset and then the
channel’s own form limits — once per part, before any part of the reply is sent. A part
that fails fails the whole reply: nothing of it is sent, the end user is told the route’s
reply_texts.error (else the built-in notice), the refusal is logged naming the property,
parameter or limit, and the record shows failed in the admin failed-delivery listing.
options are the same typed shapes a notification uses — a reply (its text
becomes the person’s next message; an author-set id echoes back on the next turn’s
params.reply_id, and a description renders as a row’s secondary line) or a link
(opens its url, submits nothing). A template carries its named components
(header_media, body_parameters, buttons) — the old flat parameters list is
removed.
Every reader that consumes a single answer string keeps working unchanged. The parts’
non-blank message texts are joined with a blank line into the same answer an api
callback, a bounded sync-wait, or a transcript reads, while a parts-aware consumer
reads the ordered parts and delivers each on its own. An agent target stays
single-string — ordered multi-message answers come from tool routes, which carry an
explicit reply contract.
How a route is authorized
Two authorities are separate and both live:- Who may send. A
channelmessage is authenticated by the provider’s own signature over the raw webhook body (fail-closed — an unsigned or mis-signed delivery is rejected and runs no turn). Anapimessage is authenticated by the access-control gate on the send door: the caller needs a key authorized for that route. - What the turn may do. The turn runs as the route’s bound
execution_key, not as whoever sent the message. That key’s live stored grants authorize the agent run (or thetooltarget’s dispatch) and every tool call the turn makes.
400, because a
route runs as an api key. The key must be evaluable by a background execution — its stored
policy condition must be resolvable with no request token — or the bind is refused.
Because the turn is authorized against the key’s live grants at fire time, you
revoke a route’s authority by attenuating its key: drop a scope, disable it, delete
it, or narrow its owner, and the very next turn is denied. There is no
revocation-specific step. Scope the execution key (or the agent) to least privilege
— the turn can do exactly what that key can do, no more.
The round trip
channel door returns the accepted id immediately; the api door
returns 202 {message_id, thread_id}, or — with a bounded wait_seconds — 200
carrying the answer inline when the turn finishes in time (a route with a callback then
suppresses it so it never double-fires). A route with no callback_url records the answer
for the poll door, GET /api/conversations/{route_name}/messages/{message_id}.
Answer outcomes
Every accepted message gets a durable answer record that moves through a delivery lifecycle:accepted— admitted, turn not yet complete (carries no answer).pending_delivery— the turn’s answer is persisted and awaiting send; this is what a restart re-drives.provisional— sent, awaiting an out-of-band delivery receipt or a grace timeout.delivered— confirmed.failed— undelivered after the attempt budget, reported failed by the provider, or a delivery row the sweep can never parse (loud, retained, and listed on the admin failed-delivery door).shed— refused by the address rate cap; no turn ran.silent— nothing is sent back for the inbound. It arises two ways: a channeltoolturn whose mapped reply wasnullor blank, or amanual-mode inbound whose suppressed target turn produced no reply (see control mode) — a due pairing reply or first-contact greeting is sent instead, so those are not silent. On an api route the silence is communicated instead — the record endsdelivered, its callback carryingstatus: "silent"and no answer.merged— this message’s text was carried into the turn named by itssuccessor_idunder an overlap policy (deliver: all).superseded— this message was dropped in favour of the turn named by itssuccessor_id(a running turn cancelled by a newer message, or a queued message overtaken by a newer one on acancelroute).
merged and superseded are answerless terminal outcomes that name the turn that took the
message’s place through a successor_id. On a channel route they are terminal and send
nothing; on an api route they are delivered as a marker exactly as silent is — the
callback carries the status and successor_id with no answer text.
A turn’s own outcome is orthogonal: answered, error, silent, merged, or superseded
— an error answer carries the failing target’s own reply when its failure carries one,
else the route’s reply_texts.error, else the built-in line, and the platform never puts an
internal detail into it (failure replies); silent,
merged, and superseded carry no answer text at all, and merged/superseded name their
successor_id. Delivery is retried
with backoff; a permanently undelivered answer ends failed rather than
disappearing.
Late failures surface too: a channel can send a 2xx and then a failed
delivery-status webhook (a message rejected outside WhatsApp’s 24-hour service
window arrives this way), which flips the record to failed. A route can see that
window before it replies: every turn carries it as
turn.reply_window
(open with its close time, closed, or none where the channel has no window).
When messages overlap
A participant does not wait their turn. A second message often lands while the turn on the first is still running. By default the platform keeps each message its own turn, strictly ordered — the second waits behind the first and then runs. A route’soverlap policy chooses a
different behaviour, in three knobs — a settle window needs deliver: all or running: cancel — (the bridge
reference has the exact fields and how
each outcome is delivered):
- Know / cancel (
running) —continueleaves a running turn alone;cancelcancels it cooperatively in favour of the newer message, which runs instead. - Carry (
deliver) —oneruns a turn per message;allcarries every message accepted since the last turn started into one turn, in order. - Settle (
settle_seconds) — hold a turn back a few seconds after its lead message so a burst rides one turn rather than several.
deliver: all the turn
simply sees the whole burst as its text (and, for a tool target, a messages list); under
cancel a turn cut short resolves superseded and delivers nothing, and its record names the
message that replaced it through successor_id.
The handler can cooperate
Cancellation is cooperative: it can only stop a turn at anawait, so it cannot claw back a
synchronous step already running, and it never reverts state the turn already wrote. Where that
matters, the handler cooperates — any code running inside a turn can read the pending seam to
learn a newer message is waiting and yield before an expensive or irreversible step:
current_conversation_turn()returns the turn in flight (its thread and lead message), orNoneoutside any turn.app.conversations.pending_messages(thread_id, after=<the lead message id>)returns the participant messages accepted after the lead and not yet carried into a turn.
TurnSupersededError(successor_id=…)
— the same outcome a cancel produces: no reply, no error, no delivery. 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. Under running: continue
this is how a handler implements “the newer message wins”: it yields, the next turn runs, and
under deliver: all the yielded message rides that turn. The handler picks which steps must not
run past a newer message; the platform stays agnostic to what it does.
Control mode and operator messages
Every thread runs in one of two control modes, and the route carries the default:agent(the default) — an inbound message buys a target turn: the agent run or the tool dispatch answers it, exactly as the round trip describes.manual— the target turn is suppressed. The inbound is recorded but the target does not answer it, so a human answers by hand. The platform’s own control turns are unaffected: pairing and the first-contact greeting still run.
initial_mode is the default; a per-thread override wins over it, so the
mode in force for a thread is its override when one is set, else the route default. A
linked person’s aggregated thread with no override defaults to manual when any
route the person spans sets initial_mode: manual, else agent — one operator control
decision covers every channel they paired. The override carries the answer records’
retention TTL and is refreshed on thread activity — each accepted inbound and each
operator send — so it expires with the conversation: a thread that falls quiet past
the retention window loses its override along with its records, and a returning address
starts again at the route default. An explicit thread
delete, or a route delete, reclaims it at once.
manual is valid for every target — a manual initial_mode, a PUT …/thread/mode, and the set_conversation_mode builtin all take it, on a tool target
or an agent alike.
In manual mode the client gets no automatic answer to an ordinary inbound: 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. The
platform’s own control turns are the exception — a pairing action still gets its reply,
and on a multichannel route a due first-contact greeting still runs: it is prepended
onto the otherwise-silent outcome, so that record ends answered and is 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 it appended to that memory, so a later agent turn once
the thread is back in agent mode reads it as prior context; a target with no thread
memory — a tool target, or an agent without the append — skips the step, its
transcript standing as the record. An append that fails is a loud error outcome, never
a silent drop.
Three parties flip the switch:
- An operator, on a named thread — the Studio’s Conversations
screen toggle,
tai conversations mode-set, or thePUT …/thread/modedoor. - The agent itself, from inside its own turn, with the
set_conversation_modebuiltin tool — it flips the current conversation (the thread the turn runs on), so an agent can hand its own conversation off to a human. The tool reads the live thread from the turn context and names none; called outside a turn it refuses loudly. - An external system, through the same
PUT …/thread/modedoor, which names its thread explicitly.
Operator messages
An operator answers a thread by hand withtai conversations send (the
POST …/thread/messages door) or the Studio compose box. No turn runs: the message is
stored already answered and delivered through the same machine a produced answer
takes — sent from the route identity, with the same chunking, ledger, and receipts.
An operator reply may itself carry media, tappable options (the same
typed reply/link shapes), a
pre-approved template, or an ask-less form’s
schema — the same rich content a produced answer can — validated up front so a bad
attachment is a clean rejection rather than a failed send. A form the channel cannot draw,
or a template whose parameters the channel cannot map, fails the record before anything is
sent, like a missing capability, with no notice to the end user. It is allowed in
either mode and never flips the mode.
The record is marked origin: "operator" (a client turn’s record is origin: "client"): it carries the operator’s text as its answer, an empty inbound_text,
and names the sending operator. For a target that keeps thread memory the text is
appended to the thread’s memory as an assistant reply — the same conditional append a
suppressed inbound takes, mirroring how the agent’s own answer enters memory — before
the record is created, so a later agent turn reads the operator’s reply as prior context;
a target with no memory skips it. An append that fails is loud and no record is created.
Memory stays continuous both ways: a manual-mode inbound the target never answered and the
operator’s own reply both sit in the thread the agent resumes on.
On a linked person’s aggregated thread the reply returns to the address they last wrote
from (a message the platform sent them does not count), or to an explicit address that
must be one of the person’s. Sending is the
door’s write action — the same grant that creates a route — and an unauthenticated
caller is refused (501), since an operator action must be attributable. See operator
messages and control mode.
Watching a route’s conversations
Every route’s threads and their transcripts are readable while they are retained, whichever door the messages arrived through. An operator reads them on the Studio’s Conversations screen — a route picker, a thread list, and a live-tailing transcript — or from the CLI withtai conversations threads and
tai conversations transcript. A thread listing spans every caller on its route, so
the listing is admin-only; a transcript read takes the same conversations read grant.
See the read doors.
The listing narrows with ?status= (a delivery-status the thread’s newest record must
match) and ?address= (a substring of the thread id’s client-address suffix); a transcript
narrows with ?q= (records whose inbound text or answer contains the substring); and
GET …/messages/search?q= searches every record on the route. None has a dedicated index, so
each is a bounded post-scan whose envelope carries a truncated flag — true when a page
spent its scan budget before the route was exhausted, so a client never mistakes a bounded
page for the whole result. See the read
doors.
Every listing also reports an unreadable count for any stored row the platform could not
decode, so a corrupt row is surfaced as a number rather than dropped from the view.
A transcript page also carries person_id: the linked person a bridge:@person:{person_id}
thread aggregates, or null for a route-keyed thread, so a reader of one open thread knows
whose aggregated history it is (and which person an erase would remove).
Each transcript record carries its origin — client for an inbound turn, operator
for a by-hand reply, send for a message the platform sent on its own (a notify_user
send, a question, a notice) — so an operator’s messages and the platform’s sends read as
distinct from the target’s answers. A send made during a turn carries that turn’s
turn_message_id. See send records. From
the same Studio screen an operator can also flip the thread’s control
mode and compose a reply by hand.
Conversation lifetime
A route’s thread key isbridge:{route_name}:{client_address}, so each client address
holds its own conversation on a route and an agent remembers earlier turns across
separate inbound messages. On any target with multichannel on, a linked person — one
whose sending address is paired to more than one address — instead keys the aggregated
bridge:@person:{person_id} thread, so all of that person’s paired channels share one
thread; every other sender keeps the bridge:{route_name}:{client_address} key.
Continuity is the checkpoint store’s: a thread lives the checkpoint waiting retention
(LLM_PROVIDER_CHECKPOINT_RETENTION_WAITING_MINUTES, 7 days by default) after its last
message; one idle past it expires and the next message starts fresh. See
retention.
Every send is in the thread. A message the platform sends to a participant — a
notify_user send, a question, a notice — is recorded in the same thread an inbound
message from them uses, and may start it. A completed send is placed by the address the
medium reports, so a send to a handle lands in the thread of the address the medium
resolved it to; a send made during a turn to its own participant stays in the turn’s
thread. With no conversations store (no CONVERSATIONS_REDIS_URL) sends still go out and
nothing is recorded. An event by thread_id on a linked person’s thread goes where the
person last wrote from; a person thread holding only send records refuses it with a 400.
An operator can also forget one thread outright — its checkpoint, its answer
records, its indexes, and the thread’s own declared state — with
tai conversations delete-thread (the
DELETE …/thread door), so the next message on that address starts fresh without
waiting for a TTL. A reset is a complete forget: the transcript and the
conversation’s own declared-state memory both go, and the delete response reports a
states_erased count beside removed. Forgetting is authorized by the same write grant
that creates the route, and a valid id on its own route always succeeds. See forgetting
a thread.
A linked person can be erased ENTIRELY with
tai conversations delete-person (the
DELETE …/persons/{person_id} door): its aggregated bridge:@person:{id} thread (checkpoint,
records, indexes), its declared state — both the person’s own cross-thread
memory and the aggregated thread’s — its person row, and every address→person index mapping go
together, so the next message from any of its addresses starts as a fresh provisional person.
The erase also clears every thread only the person and the persons it absorbed by a link
wrote in, on every route (a thread another person also wrote in is left to them), and its
answer lists the absorbed persons in absorbed_person_ids; erasing an id a /link absorbed
is a 409 that names the person it was absorbed into, whose erase clears it.
The erase reports its own states_erased count, and it is idempotent — a person already gone is
not an error. See forgetting a person.
A tool target keeps no conversation memory: the dispatch is stateless, so no
conversation state is carried across messages and each message is answered on its own. Its thread key
still groups that route’s answer records and transcripts and orders its turns; there is
simply no checkpoint to remember or expire. When the target’s multichannel is on the
payload carries person_id and person_addresses, so a tool can anchor its own
continuity on that channel-stable person even though the platform keeps no conversation
memory for it.
Answer records carry their own retention: a record is kept for a retention window
after it reaches a terminal state, then expires with its indexes.
See also
- WhatsApp to an agent — both providers, end to end.
- Connect your own system — the API door.
- Telegram / Slack to an agent.
- One bot, many skills — a relay agent.
- Conversation bridge reference — CRUD, doors, the authorization model, and every setting.

