tai42-channel-twilio
A Twilio SMS/WhatsApp channel plugin for the TAI ecosystem. It delivers an
ask 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 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 and reply-window 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). Configure the inbound webhook URL with#rp=5xx(and#rc=of at least 1) so a message refused while the server reloads is delivered again. - 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.
Replies
On WhatsApp-over-Twilio, a reply to a message from the last 7 days carries
OriginalRepliedMessageSid; the channel passes it to conversations.accept (and on the
ladder's InboundBridge when the reply answers an ask) as reply_to_medium_message_id, and
the platform resolves it into the turn's — or the asker's — typed reply_to
({message_id, status, text}). An SMS carries no reply field, so it quotes nothing.
Media and the message vocabulary (the medium's honest ceiling)
Both deliver (questions) and notify (fire-and-forget) support display
media, degraded honestly per kind to this text-first medium — so this channel
advertises supports_media_notifications:
| Media kind | SMS/MMS rendering |
|---|---|
image / video / audio |
A repeated Twilio MediaUrl attachment (a public https url Twilio fetches; the carrier applies its own per-type fallback). An inline data: image is refused with ChannelInputError — Twilio fetches a url, not inline bytes. The item's caption is alt-text a MediaUrl cannot carry, so it is dropped rather than leaked into the body. |
document |
A filename: url body line (its suggested download name — which a MediaUrl cannot carry — followed by the link; caption then bare url are the fallbacks). MMS document rendering is carrier-unreliable, so a named tappable link is the honest degradation. |
link |
A caption: url body line, or the bare url when no caption rides (SMS has no rich link cards; the human taps through). |
A content-only notify (blank message) sends its media alone: an image/video/audio-only
send goes out as a body-less MMS (the Body field is omitted, only the MediaUrl
rides — Twilio accepts that when media is present); a document/link-only send renders that
labelled line as the body.
What does NOT reach this channel (honest reachability)
The new message vocabulary adds tappable options, sectioned option lists, shared
locations, pre-approved templates, fillable forms, and interactive header/footer
enhancements. SMS/MMS supports none of them natively — no tappable buttons, no map
pin, no template component model, no form. So this channel advertises only
supports_media_notifications and declares none of
supports_interactive_notifications / supports_location_notifications /
supports_template_notifications / supports_form_notifications /
supports_form_delivery. The platform's central capability guard (notify_user and the
conversations delivery executor) reads these flags and refuses a message carrying an
unsupported shape before it ever reaches this channel — rather than letting the channel
fake an affordance or silently drop the content:
| Shape | Reaches this channel? | Why |
|---|---|---|
media (image/video/audio/document/link) |
yes | supports_media_notifications = True; rendered as above |
options (flat ReplyOption/LinkOption) |
no | needs supports_interactive_notifications — refused upstream |
sections (titled ReplyOption groups) |
no | needs supports_interactive_notifications — refused upstream |
header / footer |
no | ride an interactive surface (options/sections), so refused transitively |
location |
no | needs supports_location_notifications — refused upstream |
template (ChannelTemplate) |
no | needs supports_template_notifications — refused upstream (see below) |
schema (ask-less form) |
no | needs supports_form_notifications — refused upstream |
form delivery (an ask form) |
no | needs supports_form_delivery — refused upstream |
Templates. This channel does not use Twilio's Content API / ContentSid template
sends today — every outbound is a freeform Body (plus any MediaUrl). It therefore does
not advertise supports_template_notifications, so a ChannelTemplate is refused before it
reaches here, keeping that behaviour coherent (approved-template sends over a whatsapp:
number are the dedicated channel-whatsapp plugin's job).
A select ask (the deliver path, whose options are a plain list[str], not the
notify vocabulary) 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 the medium's honest ceiling for choice input; there is no button-tap
or reply-by-number machinery. Because an inbound SMS matches purely by its text, an
author-set option id can never round-trip over this channel — it is dropped, never
echoed back on the reply.
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 over this channel fails loudly with PendingQuestionExistsError while the first is unanswered/unexpired. The pair is released at once when the platform withdraws the first question (a cancel, a route's cancel_expr, or an erase of its thread, route or person); nothing is sent to the human |
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. The channel reports that window on every bridged turn as turn.reply_window (open for 24 hours from each inbound's first receipt, else closed; none on an SMS pair), read once per turn, so 63016 stays the final check |
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 |
| Send delivery receipts are observability only | The Twilio StatusCallback closes the loop on both legs: a send a conversation record owns (a bridge outbound, or a send recorded on a conversation route) has its delivered/failed/undelivered flip that record (a late failure ends it failed, not just the ask's timeout), and a notify_user send made outside any conversation route has its receipt resolved through the send-outcome index and posted as a delivery_receipt event on the originating trace (ERROR on failure). The send receipt is a monitoring signal only — it never re-sends or fails the run — 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 9.0.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 | Provenance | |
|---|---|---|---|---|
| tai42_channel_twilio-9.0.1.tar.gz | 70.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_twilio-9.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 110.5 kB
Release files / tai42_channel_twilio-9.0.1.tar.gz
| Download URL | tai42_channel_twilio-9.0.1.tar.gz |
|---|---|
| Size | 70.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d49ea621043d582ce1cc816b89b0c456dfb9f44ed4b5d69a6f273b7d20663776
|
|
BLAKE2b-256 checksum How to use checksums |
6890c666b954a5196a6066427401d2f03c15b88027de4483cbfb78ef4ab7234c
|
| 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-9.0.1-py3-none-any.whl
| Download URL | tai42_channel_twilio-9.0.1-py3-none-any.whl |
|---|---|
| Size | 40.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
80a7d4b5bf98f0285aa0effa306f6ca2176d44b0afe5e2c59f440f29dcad20bb
|
|
BLAKE2b-256 checksum How to use checksums |
2a5bb14a3338d810eae00bc210cb7d63b983d733e797703e7e41182b15ea955a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Security for tai42-channel-twilio 9.0.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.