tai42-channel-web
A public web chat channel plugin for the TAI ecosystem. It hosts a standalone
chat page for anonymous visitors, delivers an ask_user question into the page the
visitor is looking at, and bridges their reply back into the interactions store — so
an agent can talk to whoever opens the page, not only to people who already have an
account. It implements the tai42_contract.channels.Channel protocol and registers
under the name "web". Unlike the vendor channels (WhatsApp, Telegram, Slack) it
carries no vendor secret and needs no provider account: the plugin is both the
chat surface and the transport, and a visitor's only credential is the session
cookie the page mints for them.
The TAI ecosystem
TAI is an open-source runtime for MCP tools, agents, and workflows. A Channel is
"how a question reaches a human" — a pluggable deliverer the runtime resolves by
name when ask_user is called with channel=.... This package is one such
deliverer (public web chat); siblings back the same contract with WhatsApp,
Telegram, or Slack. This repo is this plugin's own full doc home, and the
documentation site covers the platform-level story:
- Interactions concept: https://tai42.ai/concepts/interactions
- Build a channel plugin (author guide): https://tai42.ai/guides/authors/channel
- Ecosystem catalog: https://tai42.ai/reference/catalog
Its only tai-* dependencies are tai42-contract (the Channel protocol,
ChannelDelivery, ChannelDeliveryError, and the tai42_app handle) and
tai42-kit[redis] (HttpxClient, RedisClient, TaiBaseSettings, and the
settings cache). Beyond those it depends on httpx, starlette, and pydantic /
pydantic-settings.
Install
Requires Python 3.13+. Install from PyPI into the environment that runs the server:
uv add tai42-channel-web
Or from source — clone this repo and add it as an editable dependency; the
tai42-* dependencies resolve in-tree from the workspace.
git clone https://github.com/tai42ai/tai42 # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai42/plugins/channel-web
Discovery
The runtime discovers the channel through the manifest's channel_modules key:
channel_modules: ["tai42_channel_web"]
At app load the runtime imports every module under the package, and register.py
fires the registrations as its import side-effect: the "web" channel on
tai42_app.channels, and — via the routes import — the public
/api/channels/web doors on tai42_app.http. A bare import tai42_channel_web
registers nothing — the package is library-safe; only the register module
carries the side-effect. The plugin contributes no Studio UI: its surface is the
public chat page it serves itself.
The chat page
Create a web route (channel: web) with an identity, then send visitors to:
https://<your deployment>/api/channels/web/chat/<identity>
The page is served for any identity — a name with no web route behind it surfaces
as a friendly "this chat is not available" on the visitor's first message rather
than as a dead URL. The page itself is a plugin-rendered HTML shell around a built
bundle that ships inside the wheel (src/tai42_channel_web/public/); an unbuilt
bundle is a loud 500 naming the build step, never a blank page.
Invite links
An invite link is the chat page URL with a ?pair=<code> query parameter carrying
a conversation pair code (LINK- followed by 8 [A-Z0-9] characters):
https://<your deployment>/api/channels/web/chat/<identity>?pair=LINK-ABCD1234
On load the page submits that code once, as the visitor's first message —
exactly as if they had typed it, so the conversation bridge's intercept redeems it
— and then strips the pair parameter from the URL, so a reload or a re-shared
link never resubmits it. Only a value that FULLY matches the code shape is acted
on; any other pair value is ignored entirely — never submitted, never stripped,
never reflected back into the page — and the rest of the URL is left intact.
Because the session cookie is already minted by the navigation that served the
page, an invite re-pairs a returning visitor in a single load — the counterpart to
the cookie-bound identity in Limits: a visitor who cleared cookies is a
new person until an invite (or a typed code) links them again.
Configuration
Settings are read from the CHANNEL_WEB_ environment group (see WebSettings /
WebRedisSettings). There are no credentials:
| Env var | Required | Meaning |
|---|---|---|
CHANNEL_WEB_REDIS_URL |
yes | Session registrations + transcript replay store (plugin-owned Redis); falls back to TAI_DEFAULT_REDIS_URL |
CHANNEL_WEB_REDIS_MAX_CONNECTIONS … |
no | The rest of the kit RedisConnectionSettings fields, same names under this prefix |
CHANNEL_WEB_PAGE_TITLE |
no (Chat) |
The chat page's <title> |
CHANNEL_WEB_SESSION_TTL_SECONDS |
no (2592000) | Lifetime of the session cookie and its server-side registration, refreshed on every door that resolves it |
CHANNEL_WEB_SESSION_PENDING_TTL_SECONDS |
no (600) | Lifetime a freshly minted registration gets until its cookie first comes back; then it is promoted to the full TTL |
CHANNEL_WEB_SESSION_COOKIE_SECURE |
no (true) | Secure on that cookie, and with it the cookie's name and Path (see Sessions); set false ONLY for a plain-http dev/e2e origin |
CHANNEL_WEB_HTTP_TIMEOUT_SECONDS |
no (30.0) | Answer-forward timeout to the interactions callback door, seconds |
CHANNEL_WEB_MAX_BODY_BYTES |
no (65536) | Bounded-read cap on the message and answer doors; over-cap is a 413 |
CHANNEL_WEB_MAX_STREAMS_PER_VISITOR |
no (4) | Concurrent SSE streams one visitor may hold; over is a 503 |
CHANNEL_WEB_MAX_STREAMS_TOTAL |
no (500) | Concurrent SSE streams this process may hold, across all visitors |
CHANNEL_WEB_MAX_ANSWER_RESTORES |
no (5) | How often one question's record is put back after a refused forward before it is left dropped |
CHANNEL_WEB_TRANSCRIPT_MAX_ENTRIES |
no (1000) | Exact XADD MAXLEN cap on a conversation's replay stream |
CHANNEL_WEB_TRANSCRIPT_TTL_SECONDS |
no (2592000) | Idle-transcript TTL, refreshed on every append |
CHANNEL_WEB_BACKLOG_BATCH_ENTRIES |
no (200) | Transcript entries read per page when a stream replays its backlog |
CHANNEL_WEB_KEEPALIVE_SECONDS |
no (15) | SSE keepalive cadence and the live-tail block window |
CHANNEL_WEB_BLOCKING_GRACE_SECONDS |
no (5.0) | Grace added to the tail's outer wait_for; a stalled Redis XREAD then raises loudly |
Flood control on these public doors is not this plugin's. Every
/api/channels/web/* door is public, so the app-level rate limiter throttles it as
the channels_web family (requests per caller, tuned via
TAI_RATE_LIMIT_FAMILIES__CHANNELS_WEB__LIMIT / __BURST / __ENABLED, bucketed
with TAI_RATE_LIMIT_TRUSTED_PROXIES behind a reverse proxy), and the operator's
ingress bounds what reaches the platform at all. What the plugin bounds is the one
resource it owns — the dedicated store connection an open SSE stream pins, capped
per visitor and per process — and it reads no client address to do it.
Sessions
A visitor is anonymous, and their session is two ids, minted and registered together when they open the chat page:
- a secret token (
secrets.token_urlsafe, ≥128 bits) in the visitor session cookie —HttpOnly,SameSite=Lax. This is the bearer capability and nothing else; - a visitor id, a separate opaque non-secret id the token is registered against
server-side (
channel:web:session:{token}in the plugin's Redis). That is the conversation address (client_address), the transcript key, and what anask_usernames as its recipient.
SESSION_COOKIE_SECURE decides the cookie's name, Path and Secure flag
together, because a browser honours the __Host- prefix only on a cookie that is
Secure, at Path=/, and carries no Domain — and in exchange no sibling host of
this origin can plant or overwrite it. So a Secure deployment (the default) mints
__Host-tai_web_session at /, and a plain-http one (dev / e2e), which can satisfy
none of the three conditions, mints the bare tai_web_session scoped to
/api/channels/web, where the page, its assets and the chat doors all live. The two
names are never accepted interchangeably: the mode decides one name, and a cookie
under the other is not this deployment's.
Every door reads the cookie, resolves it through the registration, and uses only the
visitor id — never the cookie value, and never a body or query value. A token with
no registration behind it is not a session: an invented cookie opens no conversation
(so it cannot mint a fresh address past the bridge's per-address turn caps), and a
planted one is replaced rather than adopted when the page loads. Resolving a session
refreshes both the cookie's Max-Age and the registration's TTL. A visitor with no
session gets 401 + "code": "session_missing", which the page answers by
re-opening the chat URL.
A session is a capability on one web route. The registration records the
identity it was minted on, and a door presented with it on any other identity
refuses exactly as it refuses an unknown token — the same 401 +
"code": "session_missing", so no session can be probed for which route it belongs
to. One browser holding sessions for two routes therefore holds two separate
conversations, and the chat page of a route the cookie does not serve mints a fresh
session rather than adopting the foreign one.
A freshly minted registration only lives SESSION_PENDING_TTL_SECONDS; the first
time its cookie comes back it is promoted to the full SESSION_TTL_SECONDS. A
cookie-less GET loop therefore leaves minute-lived keys behind, not one 30-day
registration per request. Minting is guarded once more: the page mints only for a
top-level navigation (Sec-Fetch-Dest: document, tolerating browsers that omit it),
so a cross-site subresource cannot overwrite a live visitor's cookie.
POST /api/channels/web/session/rotate deletes the old registration and mints a
fresh token + visitor id for the web route its body names — the page's "new
conversation". The old token can never address the old conversation again; that
transcript is untouched and ages out on its own TTL.
Doors
Every door is public (authed=False); the visitor's session cookie is the only
credential, and no door takes a conversation address from a body or query value.
Every door that names an identity serves it only when the caller's session was
minted on that same web route, and answers a foreign one exactly as it answers an
unknown token.
-
GET /api/channels/web/chat/{identity}— the chat page: the HTML shell plus the built bundle's hashed<link>/<script>tags. Mints and registers a session for this route whenever the cookie resolves to none, or to one minted on another route — a navigation only, else403+"code": "not_a_navigation". Carries a strict CSP (default-src 'none',script-src 'self',connect-src 'self',font-src 'self', no framing, no inline script;style-srcadmits'unsafe-inline'because the bundled design-system overlays inject a<style>element for their scroll lock).This door is reached by navigating to it, so it never answers JSON: every refusal —
403 not_a_navigation,501with no store configured,500with no usable bundle — is a minimal HTML page, with the status unchanged, and the machine-readable code carried in a<meta name="tai42-refusal-code">by the two refusals that have one. The500has none: it is a server fault with no JSON counterpart and nothing the page could do differently for. Those pages link, run and style nothing, under their owndefault-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'. -
GET /api/channels/web/assets/{file}— one file of that bundle. Only names listed in the build manifest's integrity map are served (exact-name lookup, explicit content-type map,immutablecaching); anything else is a404. -
POST /api/channels/web/messages— body{identity, text}, optionallyclient_message_id→{message_id}. The message is bridged throughconversations.accept(channel"web",our_identity = identity,client_address = <visitor id>) and, on success, appended to the visitor's transcript so their own stream replays it. Themessage_idin the reply is the id the visitor's ownchat.messageframe carries on their stream, so a page that drew the message optimistically can match the two. An identity the caller's session was not minted on →401; unroutable identity →404; blank text or a body that is not valid JSON →400; an unusable identity, an over-long text or a malformedclient_message_id→422; an over-cap body →413; a full thread queue →503.client_message_idis the page's retry key: an opaque^[A-Za-z0-9_-]{8,64}$string the browser keeps for one composed message. A POST that succeeded server-side but whose response never arrived is re-sent with the same key; the door derives the bridge'sprovider_message_idfrom that key and the conversation it goes into (identity+ the caller's own visitor id), and the bridge — idempotent on(channel, provider_message_id)— returns the first attempt'smessage_idrather than starting a second turn. Omit it and every POST mints a fresh id, so every POST is its own turn. Scoping to the whole conversation is what stops one visitor from reaching into another's dedup space and one web route's turn from answering a message sent to a different one (the bridge dedups identity-blind); neither half is the caller's to choose. The key is echoed back on the visitor's own transcript frame asclient_message_id, so a page that lost a response can retire the optimistic bubble it drew when the replay arrives; the field is absent from every frame whose sender sent no key. -
GET /api/channels/web/stream?identity=<id>— the SSE feed of the session's own conversation, keyed by(identity, visitor id): the transcript backlog, achat.backlog_donemarker, then a live tail ofchat.message/chat.question/chat.answeredframes with keepalive comments. A missing or unusableidentity→400; anidentitythe caller's session was not minted on →401; past a concurrent-stream cap →503, whose message names which ceiling was hit (this session's or the whole server's). -
POST /api/channels/web/questions/{interaction_id}/answer— body{answer}→{status: "answered"}. The answer must be one FINITE scalar (400for a body that is not valid JSON or carries noanswer,413over the body cap,422for a non-scalar, an over-long string, or a non-finite number —Infinity/NaNwould forward invalid JSON and persist an unparseable transcript frame). The pending record must belong to the caller's own conversation — both its web route identity and its address (a foreign one is reported as not found, never as "exists, but not yours"); the answer is then forwarded to the interactions callback and achat.answeredframe appended on success. A callback door that reports the question already answered elsewhere →409, with no frame appended (the recorded answer is not this visitor's). A callback door that refuses the answer →400, and the record is put back so the visitor can re-answer — up toMAX_ANSWER_RESTOREStimes, after which the question is left dropped and the refusal says so. -
POST /api/channels/web/session/rotate— body{identity}→{status: "rotated"}; unregisters the old session and sets a freshly minted cookie bound to the web route the body names. No session is required to rotate — anyone may open any route's chat page and be minted one there.
Every door but the assets door needs the store, and answers 501 +
"code": "web_transcript_store_off" when none is configured — without one there is
nowhere to register a session, so nothing downstream can work.
Success bodies are {"data": {...}}; failures are {"error": "<message>"}, plus a
"code" on the ones a caller must tell apart from the status alone:
session_missing (401), origin_mismatch (403), not_a_navigation (403) and
web_transcript_store_off (501). The chat page door is the exception — its caller is
a browser navigation, so it answers those refusals as HTML pages (above).
How a delivery is addressed
ChannelDelivery carries no sender identity, so a web ask_user names its target
as recipient = "<identity>:<visitor-id>" — the channel splits it back into the
transcript pair (on the LAST colon: a visitor id is urlsafe-minted and therefore
colon-free) and stores both in the pending-question record so the later
chat.answered frame lands on the same stream. A notify takes either shape: the
conversation bridge sets sender_identity (the web route identity) and a bare
visitor-id recipient; notify_user never sets sender_identity (that field is
the bridge's), so its recipient carries the same "<identity>:<visitor-id>"
composite a delivery uses. A composite that is not of that shape raises
ChannelDeliveryError. All four answer formats (text / confirm / select /
external) are delivered as transcript entries the page renders as widgets; only
the external entry carries the interaction's callback_url, because only its
widget opens one. The channel sends plain text only (no media, no templates).
Security
- The cookie token's unguessability IS the capability: holding the cookie is holding
the conversation. It is minted from a CSPRNG at ≥128 bits, kept
HttpOnly(page script never reads it), and accepted back only in the minted alphabet and length — and then only when a server-side registration stands behind it. - The token and the address are different ids. The address is what the bridge, the transcript keys, and the operator plane publish, so reading one grants nothing; the token is never published anywhere.
- The conversation
client_addressis always the registered visitor id, never a client-supplied value — one visitor's stream can only ever be their own conversation, and a question is answerable only from the conversation it was asked in. - A session is bound to the web route it was minted on, and every door refuses a session presented on another route exactly as it refuses an unknown token. A stolen cookie is therefore good for one route only, and the refusal never says which one it is good for.
- On a Secure deployment the cookie carries the
__Host-prefix, so no sibling host sharing this registrable domain can plant or overwrite a visitor's session. - CSRF posture: two independent legs, each of which refuses a cross-site POST on its
own —
SameSite=Laxwithholds the session cookie from it (the door then resolves no session and answers401), and anOrigincheck on every POST door refuses the mismatched origin a browser attaches to every cross-site POST (403). The JSON body is not a third leg: a cross-site form can post a JSON-shaped body withenctype="text/plain", and no door checksContent-Type. Behind a proxy theOrigincheck compares againstX-Forwarded-Proto/X-Forwarded-Hostwhen they are set. Those headers are read with no trusted-proxy check; a browser can only attach them on a preflighted cross-origin fetch, which these doors — returning no CORS headers — refuse at the preflight. The mint path additionally requires a top-level navigation, so a cross-site subresource cannot overwrite a live session cookie. - The message and answer doors read their bodies bounded (
MAX_BODY_BYTES, actual bytes, never a declaredContent-Length) and refuse an over-cap one with413, never a truncation. Anask_usercallback ticket never reaches the browser except for theexternalwidget that must open it. - The page's CSP admits scripts, stylesheets, fonts, and connections from its own
origin only, forbids framing, and the assets door serves only integrity-listed
files with an explicit content-type (never
text/html). Inline STYLE is admitted (the bundled overlays inject one<style>element for their scroll lock); inline script is not, so an injected string still has no way to run. - The transcript is a plugin-owned Redis stream with a bounded
MAXLENand a refreshed TTL; the durable record of a turn lives in the conversation bridge. - Every transcript frame is
json.dumps'd, so a newline ordata:sequence in a message body cannot inject an extra SSE frame. - Flood control is not this plugin's. Abuse control on these public doors belongs
to the platform's public-door rate limiter, which throttles the whole
/api/channels/web/*family per caller ahead of them, and to the operator's ingress. What the plugin bounds is the one resource it owns: an open SSE stream pins a dedicated store connection for its whole life, so the stream door caps concurrent streams per visitor and per process (a loud503over either). Behind those, the conversation bridge's per-address turn cap bounds LLM spend — and because a visitor mints and rotates their own visitor id, the messages door keys that cap on the request's network client bucket (the same value the public-door limiter derives), not on the resettable visitor id. - An answer forward the callback door refuses restores the question so the visitor
can re-answer, but only
MAX_ANSWER_RESTOREStimes: each forward spends a slot of that door's own rate limit, which is keyed on this server's egress IP and shared with every other channel's answer forwards. What the visitor is told about a refusal is the callback door's own error message and nothing else — a reply that is not this platform's error envelope (a proxy or WAF page, a traceback) is logged for the operator and replaced.
Limits
| Limit | Consequence |
|---|---|
| No default recipient | A web ask must name its target; a recipientless deliver/notify raises ChannelDeliveryError |
| Plain text only | A media or template notification is refused loudly — this channel advertises no media/template capability |
| Bounded replay | The transcript keeps the newest TRANSCRIPT_MAX_ENTRIES entries; older ones are trimmed (the bridge holds the durable record) |
| Single forward attempt | A failed answer forward restores the pending question so the visitor can retry; the door never blind-retries the callback |
| Bounded re-answering | One question is restored at most MAX_ANSWER_RESTORES times; after that it is left dropped and resolves by its own timeout |
| Cookie-bound conversation | A visitor who clears cookies (or opens another browser) starts a new conversation; there is no account to resume from — an invite link (?pair=) re-pairs them in one load |
| Invite links | The chat URL accepts ?pair=<LINK-code>; the page submits it once as the first message and strips it. A pair value that is not a well-formed code is ignored |
| One route per session | A session serves the web route it was minted on; a visitor who opens a second route's chat page holds a second, separate conversation |
| No plugin-side flood control | Abuse control on these public doors is the platform limiter's and the operator's ingress; the plugin caps only concurrent SSE streams |
| Store required | Every door but the assets door refuses 501 without CHANNEL_WEB_REDIS_URL — a session cannot be registered, so nothing downstream can work |
| Per-process stream cap | MAX_STREAMS_TOTAL is counted per worker, so a multi-worker deployment admits that many streams per worker |
Development
uv venv --python 3.13
uv pip install --no-sources --group dev --editable .
uv run --no-sync pytest --cov --cov-report=term-missing
uv run --no-sync ruff check .
uv run --no-sync ruff format --check .
uv run --no-sync pyright
The chat page front-end
The page's source lives in public-src/ (React + @tai42/studio-sdk, TypeScript).
pnpm build regenerates src/tai42_channel_web/public/ — the content-hashed
bundle, its stylesheet, its webfonts, and public-manifest.json — and that output
is committed, because the wheel ships it and the doors serve it. The bundle is
SELF-CONTAINED: React and the design system are compiled in, so the page needs no
import map and loads nothing from anywhere else.
pnpm install
pnpm build # regenerates src/tai42_channel_web/public/
pnpm typecheck
pnpm test # vitest + v8 coverage thresholds
pnpm format:check
tests/test_public_dist.py re-hashes the committed bundle against its manifest, so
a bundle whose bytes and declared SRI digests have drifted apart fails before it can
ship a page the browser refuses to load. Freshness — a source edit that was never
rebuilt — is CI's job: it rebuilds src/tai42_channel_web/public/ and diffs the
result against the commit.
License
Apache-2.0. See LICENSE and NOTICE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tai42_channel_web-0.3.0.tar.gz.
File metadata
- Download URL: tai42_channel_web-0.3.0.tar.gz
- Upload date:
- Size: 450.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0bfc31987328ac19227693fb09738ed0bbaf1bb18e7d75a588ec9119387a752f
|
|
| MD5 |
1eb7d980f7a78c59f9132efbfe14fadf
|
|
| BLAKE2b-256 |
494bea36364bc2dbfcf4d440a99ae113cbe3362a608cb388772ca984ff1f1f74
|
File details
Details for the file tai42_channel_web-0.3.0-py3-none-any.whl.
File metadata
- Download URL: tai42_channel_web-0.3.0-py3-none-any.whl
- Upload date:
- Size: 442.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ca973a16bc9e04946e3a134a27069708d4a46d9bcafe421057a6f86a6ad383ee
|
|
| MD5 |
bd58cf2ff07424d6e70ec860bbce1c72
|
|
| BLAKE2b-256 |
a946ab1f0bae0842f0657b9f5dff68ffc57000278c2aa93269fb25e98361bf71
|