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

PhoneTelnyxRailway APIOpenAISupabaseSMS: “is sucralose bad for me?”POST /webhooks/telnyx (Ed25519 signed)200 OK — ack first, before model workchat.completions + 3 toolstool_call: search_ingredientsSELECT … FULL_REPORT_INGREDIENTS1 rowtool result → modelfinal text (≤320 chars)POST /v2/messagesSMS replymessage.finalized (delivery receipt)

Step by step

  1. 1

    Telnyx receives the SMS and POSTs the webhook

    Configured on the messaging profile. Body is the message.received event.

    {
      "data": {
        "event_type": "message.received",
        "payload": {
          "direction": "inbound",
          "from": { "phone_number": "+16175550123" },
          "to":   [{ "phone_number": "+14015988402" }],
          "text": "is sucralose bad for me?"
        }
      }
    }
  2. 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 200 first and continues asynchronously.

  3. 3

    Signature verification — fails closed

    Ed25519 over `${timestamp}|${rawBody}`, which is why express.json retains the raw buffer — verifying a re-serialized object would fail. Requests older than 300s are rejected as replays.

    If TELNYX_PUBLIC_KEY is 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=1 bypasses this for local dev only.

  4. 4

    Keyword handling

    STOP/UNSUBSCRIBE/CANCEL/END/QUIT clear history and return without replying — Telnyx handles carrier-mandated opt-out itself, and texting over it would be a compliance problem. RESET clears context and confirms.

  5. 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. 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

PropertyValueWhy
StoreIn-process MapThrowaway context; persisting health questions is a liability
Retention8 messages (4 exchanges)Enough for “what about the other one?”
TTL30 minutesIdle sweep every 10 min
Survives redeployNoCosts 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 webhook

No 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.