tai42-channel-twilio
A Twilio SMS/WhatsApp channel plugin for the TAI ecosystem. It delivers an
ask_user question to a human's phone through the Twilio Messages API and
bridges the human's reply back into the interactions store — so an agent can
reach a person out-of-band instead of only showing the question in the Studio
inbox. It implements the tai42_contract.channels.Channel protocol and registers
under the name "twilio".
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 (Twilio SMS/WhatsApp); siblings back the same contract with Telegram
or Slack. The ecosystem is open-ended: any package can back the same contract,
so 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. There is no Twilio SDK: the send is one Basic-auth form POST over
httpx, and webhook signature validation is ~30 lines of stdlib
hmac/hashlib/base64.
Install
Requires Python 3.13+. Install from PyPI into the environment that runs the server:
uv add tai42-channel-twilio
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-twilio
Discovery
The runtime discovers this plugin through the manifest's channel_modules key:
channel_modules: ["tai42_channel_twilio"]
At app load the runtime imports every module under the package, and
register.py fires the registrations as its import side-effect: the "twilio"
channel on tai42_app.channels, and — via the inbound import — the
unauthenticated webhook route on tai42_app.http. A bare
import tai42_channel_twilio registers nothing — the package is library-safe;
only the register module carries the side-effect.
Configuration
Settings are read from the CHANNEL_TWILIO_ environment group (see
TwilioSettings / TwilioRedisSettings):
| Env var | Required | Meaning |
|---|---|---|
CHANNEL_TWILIO_ACCOUNT_SID |
yes | Twilio Account SID (Basic-auth username, URL path segment) |
CHANNEL_TWILIO_AUTH_TOKEN |
yes | Auth token (SecretStr) — Basic-auth password AND webhook signature HMAC key |
CHANNEL_TWILIO_FROM |
yes | The deployment's Twilio number (E.164, or whatsapp:+...) |
CHANNEL_TWILIO_DEFAULT_RECIPIENT |
for recipient-less asks | Destination number used when the caller requests no recipient (E.164, or whatsapp:+...) |
CHANNEL_TWILIO_ALLOWED_RECIPIENTS |
for caller-requested recipients | Whitelist a caller-requested recipient must be on — comma-separated or a JSON list; an unlisted request is refused loudly |
CHANNEL_TWILIO_REDIS_URL |
yes | Correlation store (plugin-owned Redis) |
CHANNEL_TWILIO_REDIS_MAX_CONNECTIONS … |
no | The rest of the kit RedisConnectionSettings fields, same names under this prefix |
CHANNEL_TWILIO_HTTP_TIMEOUT_SECONDS |
no (30.0) | Outbound send + answer-forward timeout, seconds |
CHANNEL_TWILIO_DEDUPE_TTL |
no (172800) | Seen-MessageSid replay-guard window, seconds |
Recipient policy is operator-owned: a caller may request a recipient per ask,
but it is sent to only if it is on CHANNEL_TWILIO_ALLOWED_RECIPIENTS (fail
closed — an unlisted recipient raises, nothing is sent); with no requested
recipient the ask goes to CHANNEL_TWILIO_DEFAULT_RECIPIENT, which is trusted
without an allowlist check. Secrets live only in the environment.
Two steps happen out-of-band (the plugin never mutates Twilio account configuration at startup):
- Point the Twilio number's "A message comes in" webhook at
{public base URL}/api/channels/twilio/inbound, HTTP POST (Twilio console or REST API). - Deploy behind a TLS-terminating proxy you control that sets
X-Forwarded-Proto/X-Forwarded-Host— the webhook signature is validated against the public URL reconstructed from those headers, so it always tracks what Twilio actually called.
How a human answers
A text or select question arrives as a normal SMS (a select ask lists its
options numbered). The human just replies — no code to quote, no prefix.
Correlation is fully out-of-band: any reply from the configured number resolves
the pair's pending question, so one question can be pending per number pair at
a time; a second concurrent one is rejected loudly.
A confirm or external question arrives as a tappable link and is answered
in the browser via the callback door — no SMS reply is expected or matched, and
it never consumes the number pair.
Media and options (the medium's honest ceiling)
Both deliver (questions) and notify (fire-and-forget) support display
media: an image media item attaches as MMS/WhatsApp media (a repeated Twilio
MediaUrl — a public https url; an inline data: image is refused with
ChannelInputError, since Twilio fetches the url), and a link item is appended
to the message body as a labelled line (SMS has no rich link cards). This channel
therefore advertises supports_media_notifications. A media-only notify (a
caption-less image, blank message) sends a body-less MMS: the Body field is
omitted and only the MediaUrl rides — Twilio accepts that when media is present.
An SMS/MMS message has no tappable buttons. So this channel deliberately does
not advertise supports_interactive_notifications: a notify carrying
tappable options is refused up front by the runtime rather than sent with a fake
affordance. A select ask still renders its options as numbered body lines,
and the human answers by typing one option — the inbound webhook forwards the
typed reply verbatim to the callback door, which validates it against the option
set. That typed-reply mapping is a real answer path, and it is the medium's honest
ceiling for choice input; there is no button-tap or reply-by-number machinery.
Security
- Inbound requests authenticate via
X-Twilio-Signature:base64(HMAC-SHA1(auth_token, public_url + sorted form pairs)), validated fail-closed with a constant-time compare before one byte of the body is trusted. A missing/empty auth token is an operator error that raises loudly — never a soft 401 that reads like a bad signature. - Twilio's signature scheme carries no timestamp, so a captured request
validates forever; the
MessageSiddedupe window (48h default) plus HTTPS are the replay guards. - The auth token is a
SecretStr— it never surfaces in a repr, log line, or traceback; the plaintext is read only at the Basic-auth and HMAC seams. - The unauthenticated route bounds its body read (1 MiB → 413) before any signature work.
v1 limits
| Limit | Consequence | Future |
|---|---|---|
Recipients fixed by operator env (_ALLOWED_RECIPIENTS/_DEFAULT_RECIPIENT) |
A question can only reach a whitelisted or default number — no dynamic/unlisted destinations | Directory-backed recipient resolution |
| One pending question per number pair | A second concurrent ask_user over this channel fails loudly with PendingQuestionExistsError while the first is unanswered/unexpired |
Number pool via Messaging Service sticky sender |
| SMS-first; WhatsApp freeform only | With whatsapp: numbers, a question outside the human's 24h session window is rejected by Twilio (error 63016) as a loud delivery failure |
Approved-template (ContentSid) sends |
| Single send attempt | A transient Twilio outage fails the ask instead of retrying (no idempotency key exists → a blind retry risks double-texting) | App-side dedupe + retry |
| Flow-send delivery receipts are observability only | The Twilio StatusCallback closes the loop on both legs: a bridge outbound's delivered/failed/undelivered flips the answer record (a late failure ends it failed, not just the ask's timeout), and a flow send (notify_user, which runs no bridge record) has its receipt resolved through the send-outcome index and posted as a delivery_receipt event on the originating flow trace (ERROR on failure). The flow-send receipt is a monitoring signal only — it never re-sends or fails the flow — and correlates only when a trace was ambient at the send and the interactions store is configured; a status neither leg owns is acked and logged |
— |
| No timestamp in Twilio's signature scheme | Replay of a captured request validates forever for that URL+body; MessageSid dedupe (48h default window) + HTTPS are the guards | — (Twilio protocol property) |
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 live integration suite (pytest -m integration) sends real messages via
the Twilio API and runs only when the CHANNEL_TWILIO_* credentials are
present in the environment; it skips cleanly otherwise.
License
Apache-2.0. See LICENSE and NOTICE.
Correlation surface (2.0)
Since 2.0 inbound answers resolve through the platform's shared inbound-answer
ladder: the plugin exposes its correlation store over the contract's
CorrelationStore port (reserve / peek / release) plus a transport ack, and the
skeleton owns the forward / retry-in-place / bridge ladder. The plugin-local
pop/restore correlation helpers from 1.x are gone.
Metadata
Release files for tai42-channel-twilio 2.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tai42_channel_twilio-2.2.1.tar.gz | 46.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_twilio-2.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 75.5 kB
Release files / tai42_channel_twilio-2.2.1.tar.gz
| Download URL | tai42_channel_twilio-2.2.1.tar.gz |
|---|---|
| Size | 46.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
25ed35cf5353917bcc927355005d5deaf0691239924a2f3ccff98aaef4d9df6f
|
|
BLAKE2b-256 checksum How to use checksums |
44eb908575134fdf33ef1a233ab30d3e77f02a60022fadecdb0ccd15a031422c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / tai42_channel_twilio-2.2.1-py3-none-any.whl
| Download URL | tai42_channel_twilio-2.2.1-py3-none-any.whl |
|---|---|
| Size | 29.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
575a87a77835879bb6a5497eb518b5c02c4343d83d1a3eaad3efa69dcc6c8a33
|
|
BLAKE2b-256 checksum How to use checksums |
4c05c584a97694bbcae57632ddb0596b9a813dfed01bc639eded2ca9830e4fef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|