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 the channel 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_url, or returned inline on a bounded sync wait.
The routing table
A route is one row in the conversation routing table. It binds an inbound door to the agent a turn runs 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 agents.
Manage the table with the tai conversations CLI or the /api/conversations
routes; see the bridge reference.
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 agent 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 and every tool call the turn makes.
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 (its callback is then
suppressed so it never double-fires).
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, or reported failed by the provider (loud, retained, and listed on the admin failed-delivery door).shed— refused by the address rate cap; no turn ran.
answered, or error — an error answer
carries generic client-safe text, never an internal detail. 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.
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. Continuity is the checkpoint store’s: with an
idle TTL configured (checkpoint_ttl_minutes), a thread idle past the lifetime is
swept and the next message starts fresh; with no TTL the thread is kept. See
retention.
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 triage agent.
- Conversation bridge reference — CRUD, doors, the authorization model, and every setting.

