Skip to main content
The conversation bridge turns an inbound message — from a messaging channel like WhatsApp, Telegram, or Slack, or from an authed API caller — into a turn whose answer is durably stored and delivered back to the client. One routing table binds each inbound identity to what answers it — an agent run or a direct tool dispatch — and the key that turn runs as.

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 (see tai 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 caller POSTs to /api/conversations/{route_name}/messages. The answer is delivered by a signed HTTPS callback to the route’s callback_url when it declares one, returned inline on a bounded sync wait, or read back from the poll door when it declares none.
A blank or whitespace-only message is refused before any turn runs: the 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

A target_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_expr maps the inbound payload {message, sender, our_identity, channel, route, turn} to the run’s kwargs — a tool’s kwargs, or an agent’s astream kwargs; without it a tool gets those six keys (plus event on an event turn) and an agent reads the rendered turn text.
  • reply_expr maps the run’s result to the reply text; without it a tool result must itself be a string or null. It only ever sees a returned result: a run that fails (a run’s error, stopped, or aborted terminal among them) skips the mapping and resolves as the error outcome 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.
The payload also carries a 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: an agent 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 of MediaItems the person sent with the text (image/document/video/audio), the inbound counterpart of an outbound answer’s media. It rides under an attachments payload key.
  • location — a geographic point the person shared, a LocationElement (latitude/longitude with an optional name/address). It rides under a location payload 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 a reply_to payload key. See replies to an earlier message.
All three follow the 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 the Person row, seeded from the channel at first contact and canonical in storage. Set or clear it through PUT /api/conversations/persons/{person_id}/locale (the same write grant that forgets a thread) or the CLI’s tai conversations set-person-locale / read it with tai 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 default locale (--locale), else None. It is what the renderer reads for every template and the list_format filter.
A locale change while the person is being erased answers 503, and nothing changes.

Rich, multi-message answers

A tool 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:
  1. Who may send. A channel message 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). An api message is authenticated by the access-control gate on the send door: the caller needs a key authorized for that route.
  2. 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 the tool target’s dispatch) and every tool call the turn makes.
Binding the execution key is a delegation decision taken when the route is created: you may bind your own identity when you are an api key, or a key you own, or any key as an admin (a pass-role check); an account identity is refused with a 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.
A public trigger-auth hook can be fired by anyone, with no credential — so the bound execution_key is the only thing bounding what a fired hook can do. Treat the key as the security boundary: bind a least-privilege key, and never bind an admin or broad key to a public (or token) hook — that lets any caller act with those privileges. Use verifier or api_key triggers when the fire itself must be authenticated.

The round trip

The inbound door returns as soon as the message is accepted and its answer record persisted; the turn runs in the background and the answer is delivered when it completes. A 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 channel tool turn whose mapped reply was null or blank, or a manual-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 ends delivered, its callback carrying status: "silent" and no answer.
  • merged — this message’s text was carried into the turn named by its successor_id under an overlap policy (deliver: all).
  • superseded — this message was dropped in favour of the turn named by its successor_id (a running turn cancelled by a newer message, or a queued message overtaken by a newer one on a cancel route).
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’s overlap 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) — continue leaves a running turn alone; cancel cancels it cooperatively in favour of the newer message, which runs instead.
  • Carry (deliver) — one runs a turn per message; all carries 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.
The policy is a generic conversation mechanism: it governs participant message turns only, and the target — an agent or a tool — needs to know nothing about it. Under 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 an await, 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), or None outside 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.
A body that finds something pending stops early by raising 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.
A route’s 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 the PUT …/thread/mode door.
  • The agent itself, from inside its own turn, with the set_conversation_mode builtin 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/mode door, which names its thread explicitly.

Operator messages

An operator answers a thread by hand with tai 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 with tai 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 is bridge:{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