Skip to main content

Twilio SMS/WhatsApp channel plugin for the TAI ecosystem — delivers ask_user questions to a human's phone and bridges the reply back.

Project description

tai42-channel-twilio

CI License: Apache 2.0

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:

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:

git clone https://github.com/tai42ai/tai-channel-twilio   # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai-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.

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_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
No StatusCallback DLR handling Delivery confirmation is Twilio's synchronous accept only; an undelivered SMS surfaces as the ask's timeout StatusCallback route for sent/delivered/failed
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
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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tai42_channel_twilio-0.1.1.tar.gz (38.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tai42_channel_twilio-0.1.1-py3-none-any.whl (24.2 kB view details)

Uploaded Python 3

File details

Details for the file tai42_channel_twilio-0.1.1.tar.gz.

File metadata

  • Download URL: tai42_channel_twilio-0.1.1.tar.gz
  • Upload date:
  • Size: 38.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for tai42_channel_twilio-0.1.1.tar.gz
Algorithm Hash digest
SHA256 35cfa289871669b250c2ad690b7f73d1a5d1569f356ad22239e2f8f8f19dad1c
MD5 8148c2ec7ab8cfe56710414e9d357bc2
BLAKE2b-256 7f3a8dbaf41e2bc3bf18703d7c275a807fa00ac62e9f56bf0926792fc216213a

See more details on using hashes here.

File details

Details for the file tai42_channel_twilio-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: tai42_channel_twilio-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 24.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for tai42_channel_twilio-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ba3de258f83a237666bf2d8a7cea923013181060914e974935af6acfebf0277b
MD5 14fe523a25bbc262a3ba3b056912f741
BLAKE2b-256 86730aaa0905aec7a71a15ed5286568023d3e9d3c12c791583e2b67a5b136431

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page