SMS — “Text Iggy”
A user texts +1 401 598 8402 and gets an answer back in one or two message segments. Telnyx carries the messages; the Railway API does the thinking.
Sample flow
Step by step
- 1
Telnyx receives the SMS and POSTs the webhook
Configured on the messaging profile. Body is the
message.receivedevent.{ "data": { "event_type": "message.received", "payload": { "direction": "inbound", "from": { "phone_number": "+16175550123" }, "to": [{ "phone_number": "+14015988402" }], "text": "is sucralose bad for me?" } } } - 2
The API acks immediately — before any model work
The model round-trip takes longer than Telnyx's webhook timeout. Replying late means Telnyx retries, which means duplicate SMS to the user, billed each time. So the handler returns
200first and continues asynchronously. - 3
Signature verification — fails closed
Ed25519 over
`${timestamp}|${rawBody}`, which is whyexpress.jsonretains the raw buffer — verifying a re-serialized object would fail. Requests older than 300s are rejected as replays.If
TELNYX_PUBLIC_KEYis unset the webhook is rejected, not accepted. An open webhook lets anyone who learns the URL forge inbound messages — spending our OpenAI budget and, once numbers carry subscriptions, impersonating a paying user.TELNYX_ALLOW_UNVERIFIED=1bypasses this for local dev only. - 4
Keyword handling
STOP/UNSUBSCRIBE/CANCEL/END/QUITclear history and return without replying — Telnyx handles carrier-mandated opt-out itself, and texting over it would be a compliance problem.RESETclears context and confirms. - 5
The agent loop runs
Up to 4 rounds of tool calls. All tools requested in a round run before returning to the model, so a multi-lookup question costs one extra round rather than one per lookup. A tool that throws returns its error to the model instead of bubbling up, so the user gets "I couldn't look that up" rather than silence.
- 6
Reply is sent and history recorded
Sent from the number the message arrived on. The exchange is appended to in-memory history keyed by phone number.
SMS-specific prompt
The agent takes an sms: true flag that appends a length constraint to the system prompt:
You are replying over SMS. Keep it under 320 characters —
roughly two message segments. No markdown, no bullet lists,
no links unless asked. One tight paragraph.SMS bills per segment and markdown renders as literal asterisks on a phone. The same agent without this flag powers /ask.
Conversation memory
| Property | Value | Why |
|---|---|---|
| Store | In-process Map | Throwaway context; persisting health questions is a liability |
| Retention | 8 messages (4 exchanges) | Enough for “what about the other one?” |
| TTL | 30 minutes | Idle sweep every 10 min |
| Survives redeploy | No | Costs a user one repeated question |
Delivery is not acceptance
This has already bitten us once
Telnyx returns 2xx when it accepts a message, not when a carrier delivers it. For a while the logs said replied for messages that never arrived — the number was not assigned to a 10DLC campaign, so carriers dropped every one.
The message.finalized / message.failed webhook is now handled and logs the real per-carrier outcome. Trust that line, not replied.
Log lines worth knowing
[telnyx] inbound from +1617… : is sucralose bad for me?
[telnyx] accepted id=… to=… from=… status=queued
[telnyx] replied to +1617…
[telnyx] delivery id=… to=… status=delivered
[telnyx] delivery id=… status=delivery_failed errors=[40010:…]
[telnyx] signature verification failed — dropping webhookNo inbound line means Telnyx never reached us — a webhook URL problem, not a code problem.
Testing without a phone
curl -X POST https://api.ingredientchecker.app/ask \
-H "Content-Type: application/json" \
-d '{"message":"is sucralose bad for me?","sms":true}'Same agent and tools, no Telnyx involved. Useful for separating an agent bug from a delivery problem.