Skip to main content

tai42-channel-twilio

License: Apache 2.0

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:

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):

  1. 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).
  2. 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 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 MessageSid dedupe 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 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
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 notify_user send (which runs no bridge record) 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 5.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tai42-channel-twilio 5.1.0
File Size Uploaded
tai42_channel_twilio-5.1.0.tar.gz 50.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tai42-channel-twilio 5.1.0
File Interpreter ABI Platform
tai42_channel_twilio-5.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 81.9 kB

Release files / tai42_channel_twilio-5.1.0.tar.gz

Download URL tai42_channel_twilio-5.1.0.tar.gz
Size 50.6 kB
Tags Source
SHA-256 checksum
How to use checksums
43eb0b6a4bc1ae122e2d4a2a6f3e5f5bb769d12b3d7ca699ddffa0416c7863c7
BLAKE2b-256 checksum
How to use checksums
9b96aa05f4e35be75f968457608f03cc12188ab2945febb2025b9c4aae25ff2d
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-5.1.0-py3-none-any.whl

Download URL tai42_channel_twilio-5.1.0-py3-none-any.whl
Size 31.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5fa09d5a34a8a4c9b9e7844402dd020bf61936bca5544f513df88cb4a82dfb2e
BLAKE2b-256 checksum
How to use checksums
ecce4aa51dfc8b87c3186a45fa14c2e087ba256139244ff0ddc249bb9b0fb284
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

7.0.6

2 release files

7.0.5

2 release files

7.0.4

2 release files

7.0.3

2 release files

7.0.2

2 release files

7.0.1

2 release files

7.0.0

2 release files

6.0.1

2 release files

6.0.0

2 release files

5.3.4

2 release files

5.3.3

2 release files

5.3.2

2 release files

5.3.1

2 release files

5.3.0

2 release files

5.2.3

2 release files

5.2.2

2 release files

5.2.1

2 release files

5.2.0

2 release files

5.1.2

2 release files

5.1.1

2 release files

This release

5.1.0 This release

2 release files

5.0.3

2 release files

5.0.2

2 release files

5.0.1

2 release files

5.0.0

2 release files

4.0.9

2 release files

4.0.8

2 release files

4.0.7

2 release files

4.0.6

2 release files

4.0.5

2 release files

4.0.4

2 release files

4.0.3

2 release files

4.0.2

2 release files

4.0.1

2 release files

4.0.0

2 release files

3.2.1

2 release files

3.2.0

2 release files

3.1.1

2 release files

3.1.0

2 release files

3.0.1

2 release files

3.0.0

2 release files

2.2.1

2 release files

2.2.0

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.1.4

2 release files

1.1.3

2 release files

1.1.2

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page