Skip to content

SMS Channel

The SMS channel lets people text a phone number and get answers from a PinionAI agent. Inbound texts arrive from Telnyx as signed webhooks, chat_sms.py runs them through the agent like any other chat client (Slack, Teams), and the reply goes back by SMS from the number that was texted.

For one-way texts sent by an agent (order alerts, codes), use a send sms delivery instead; see Delivery.

How it works

  1. Someone texts your Telnyx number. Telnyx POSTs a message.received event to https://<your host>/webhooks/sms/telnyx.
  2. The service checks the Ed25519 signature and the timestamp (events older than five minutes are rejected), answers 200 at once, and handles the text in the background. Telnyx retries are recognized by event id and ignored.
  3. Each conversation is one pair of numbers (your number, their number) with one agent session. The sender's number is set as phone_number and channel is set to sms in the session variables, so intents and deliveries can use {{var.phone_number}}.
  4. The agent's reply is flattened to plain text (markdown and HTML removed, link URLs kept) and sent in messages of up to 1,600 characters, at most SMS_MAX_PARTS of them. File responses are sent as their URL.
  5. The session is saved to the host after every turn. If the service restarts, the next text re-hydrates the same session as long as the conversation was active within SMS_CONVERSATION_TTL_HOURS; after that, a new session starts.

Other rules:

  • Opt-out keywords (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, START, UNSTOP) never reach the agent; Telnyx enforces opt-out on the messaging profile. HELP and INFO get SMS_HELP_TEXT.
  • Hand-off: when the agent requests a transfer, the service sends its reply, then SMS_HANDOFF_TEXT, and stops answering that number until the conversation expires, so a person can follow up.
  • Rate limit: each number gets at most SMS_RATE_PER_HOUR answered texts per hour.
  • Errors: if a turn fails or times out, the person gets SMS_ERROR_TEXT and the next text starts from the host's copy of the session.
  • Privacy: logs show only the last four digits of phone numbers and never the message text (except with SMS_DRY_RUN).

Telnyx setup

  1. Buy or port a number and assign it to a messaging profile.
  2. Complete 10DLC (or toll-free) registration; US carriers block unregistered application traffic.
  3. On the messaging profile, set the inbound webhook URL to https://<your host>/webhooks/sms/telnyx.
  4. Under Keys & Credentials, copy the account's public key (for TELNYX_PUBLIC_KEY) and create an API key (for TELNYX_API_KEY).

A messaging profile has one webhook URL, so every number on that profile is answered by this service. Use a separate profile for numbers that should go elsewhere.

Configuration

Variable Required Description
host_url Yes PinionAI API URL, e.g. http://127.0.0.1:8080.
client_id, client_secret Yes Account client credentials.
agent_id Yes* Agent that answers texts. *Not needed if SMS_AGENT_MAP covers every number.
SMS_AGENT_MAP No JSON object of number → agent id, e.g. {"+18445550001": "<agent uid>"}.
TELNYX_API_KEY Yes Telnyx API key used to send replies.
TELNYX_PUBLIC_KEY Yes Telnyx account public key used to verify webhooks.
version No Agent version to load.
SMS_SESSION_VARS No JSON object of variables set on every new session, e.g. {"locale": "en"}.
SMS_STATE_DB No SQLite file mapping conversations to sessions. Default sms_state.db.
SMS_CONVERSATION_TTL_HOURS No How long a conversation keeps its session. Default 24.
SMS_IDLE_SECONDS No Idle time before a session is unloaded from memory (it stays on the host). Default 1800.
SMS_TURN_TIMEOUT No Seconds allowed for each agent step. Default 90.
SMS_RATE_PER_HOUR No Answered texts per number per hour. Default 30.
SMS_MAX_PARTS No Maximum messages per reply. Default 3.
SMS_MAX_CONCURRENT No Turns processed at once across all conversations. Default 8.
SMS_HELP_TEXT, SMS_HANDOFF_TEXT, SMS_ERROR_TEXT No Fixed replies for HELP, hand-off and errors.
SMS_DRY_RUN No 1 logs replies instead of sending them (no TELNYX_API_KEY needed).
SMS_HOST, PORT No Listen address. Default 127.0.0.1:8086.

Running

python chat_sms.py

GET /healthz reports the number of loaded conversations.

The service listens on 127.0.0.1 by default; publish it through your reverse proxy. With Apache, add a path to an existing HTTPS virtual host, before its catch-all ProxyPass /:

ProxyPass        /webhooks/sms/ http://127.0.0.1:8086/webhooks/sms/
ProxyPassReverse /webhooks/sms/ http://127.0.0.1:8086/webhooks/sms/

If the host is behind Cloudflare, add a WAF rule that skips bot challenges for /webhooks/sms/*; Telnyx cannot answer a challenge.

Testing without sending

Use a fixed test key pair (never with real Telnyx traffic). Its public key is:

python -c "import base64; from nacl.signing import SigningKey; print(base64.b64encode(bytes(SigningKey(b'pinion-sms-dry-run-test-key-0001').verify_key)).decode())"

Start the service with SMS_DRY_RUN=1 and that value as TELNYX_PUBLIC_KEY, then POST a signed event:

import base64, json, time, httpx
from nacl.signing import SigningKey

key = SigningKey(b"pinion-sms-dry-run-test-key-0001")
body = json.dumps({"data": {"id": f"test-{time.time()}", "event_type": "message.received", "payload": {
    "from": {"phone_number": "+15551234567"}, "to": [{"phone_number": "+18445550001"}], "text": "Hi"}}})
ts = str(int(time.time()))
sig = base64.b64encode(key.sign(f"{ts}|{body}".encode()).signature).decode()
print(httpx.post("http://127.0.0.1:8086/webhooks/sms/telnyx", content=body,
                 headers={"telnyx-timestamp": ts, "telnyx-signature-ed25519": sig}).status_code)

The agent's reply appears in the service log.