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
- Someone texts your Telnyx number. Telnyx POSTs a
message.receivedevent tohttps://<your host>/webhooks/sms/telnyx. - The service checks the Ed25519 signature and the timestamp (events older than five minutes are rejected), answers
200at once, and handles the text in the background. Telnyx retries are recognized by event id and ignored. - Each conversation is one pair of numbers (your number, their number) with one agent session. The sender's number is set as
phone_numberandchannelis set tosmsin the session variables, so intents and deliveries can use{{var.phone_number}}. - 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_PARTSof them. File responses are sent as their URL. - 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.HELPandINFOgetSMS_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_HOURanswered texts per hour. - Errors: if a turn fails or times out, the person gets
SMS_ERROR_TEXTand 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
- Buy or port a number and assign it to a messaging profile.
- Complete 10DLC (or toll-free) registration; US carriers block unregistered application traffic.
- On the messaging profile, set the inbound webhook URL to
https://<your host>/webhooks/sms/telnyx. - Under Keys & Credentials, copy the account's public key (for
TELNYX_PUBLIC_KEY) and create an API key (forTELNYX_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
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.