Texting the agent (SMS routing)

How an incoming text finds the right conversation, and how to test it without a phone number

Ticket: ZLY-2016. Builds on ZLY-1815 (SMS replies with a code).

The problem in one line

Users can text their agent. When a text starts with the 5-character code from our last message (AB3KD yes), we know the conversation. When it doesn't, we used to throw it away. Now we work out where it goes.

How it works

When a text arrives, we check these in order and stop at the first match:

  1. It answers our question. If we asked "Reply 1 for Nike, 2 for Adidas" and they reply with just a number, their original message goes to that conversation. They don't retype it.
  2. It starts with a valid code. It goes to that conversation, as before. A first word that only looks like a code ("Great, thanks") but matches none of their conversations is treated as normal text.
  3. No brand conversations. It goes to their personal chat with the agent.
  4. One brand conversation. It goes there.
  5. Several brand conversations. The AI classifier (Jev) picks one of them, or their personal chat when the text is about the user themselves (rates, availability, profile).
    • If it is sure (70% or more), the text goes there.
    • If it is not sure, we text back: "Which conversation is this for? Reply 1 for Nike, 2 for Adidas, or 3 for your own agent." The personal chat is always the last option. The question stays open for 24 hours. Anything other than a number counts as a new text, and the old question is dropped.

Either way, the agent's answer comes back by text, exactly like the web chat.

Counts as a conversation: a brand conversation the user is in that is not archived, not closed, not a demo or rehearsal, and not a chat with themselves.

Sender lookup: we match the sender's number against the phone on the user's Profile. The Profile field now asks for the country code. A number typed with it (+1 555 123 4567) is saved in one standard format (+15551234567). A local number (0948 123 4567) is saved as typed, because we can't tell its country, but it still matches: a text from +63 948 123 4567 finds it. Older entries like (555) 123-4567 match too.

Turning SMS on

SMS stays off until the person turns it on in Profile. The switch is off when they open the page. Next to it is the full notice: recurring customer care and deal updates, that consent is not required to buy anything, that message frequency varies, that message and data rates may apply, and that they can reply STOP or HELP. Terms of Service and Privacy Policy open from that notice.

Turning the switch on saves when they agreed, the mobile number, and that they were on their profile. A different number needs the switch again. Turning it off, or replying STOP, stops future SMS texts. HELP replies with how to reach Zooly at info@zooly.ai, plus STOP and START.

Replying STOP

Ticket: ZLY-2027.

A welcome SMS, when one is sent, ends with "Reply STOP anytime to stop texts." Other SMS texts leave it off. It is not sent at signup unless the person has already turned SMS on.

Replying STOP turns SMS off, the same as the switch on the Profile page. Unsubscribe, cancel, end, and quit do the same when they are the whole message. START turns SMS back on after they had already opted in. HELP replies with support email info@zooly.ai, plus how to stop or start. None of these are chat messages, so no conversation gets them and the agent does not answer. Turning the Profile switch back on also restarts SMS. A sentence that only contains the word ("stop sending me Nike stuff") is a normal message.

Testing without a phone number

There is no real number yet, so we fake both directions:

  • Incoming: call our webhook directly with a made-up sender number.
  • Outgoing: with TWILIO_SMS_MOCK=1, every text we would send is printed in the app's terminal as [SMS mock] to +1555…: … instead of being sent. The test script also prints it as REPLY ….

One-time setup

  1. In apps/zooly-app/.env.local add:
    TWILIO_SMS_MOCK=1
    Z_AGENT_SMS_INBOUND_SECRET=any-long-random-string
  2. Apply the database change (already done on this machine): npm run db:migrate in packages/db. On Windows without bash, run npx tsx --env-file=../../.env.local src/migrate.ts there instead.
  3. Restart the app (npm run dev in apps/zooly-app, port 3004).
  4. Sign in as a fresh test user (see tests/manual/auth-login.md).
  5. Go to Profile and set the phone to a made-up number, for example +15550001111.

Sending a fake text

From packages/z-agent/srv:

npx tsx scripts/verify-sms-inbound.ts --from +15550001111 --body "hello"

The output shows status, routedVia (how it was routed), and REPLY (what we would have texted back).

Test cases

#SetupSendPass when
1User has no brand conversations, and has opened their agent chat once (that creates it)helloprocessed, routedVia: home. The reply appears in the user's personal chat on the web too.
2Create one brand conversation (Conversations + → "Bring a conversation to Z", e.g. "Nike wants a summer shoot for $5k")sounds goodprocessed, routedVia: single. The message and reply appear in the Nike conversation on the web.
3Add a second brand (e.g. "Adidas wants 3 Instagram posts")About the Adidas posts, can we do 4?processed, routedVia: jev, in the Adidas conversation.
4Same two brandsCan I raise my rate to $5k for everything?processed, routedVia: jev, in the personal chat.
5Same two brandsokawaiting_choice. REPLY is "Which conversation is this for? Reply 1 for …, 2 for …, or 3 for your own agent."
6Right after test 52processed, routedVia: choice. The original "ok" shows up in the conversation numbered 2.
7Same two brandsGreat, thanksNot ignored. It is routed by Jev or gets the question, never "code not matched".
8Anyhello from an unknown number (--from +15559999999)ignored, reason no conversations for phone.
9AnySend the same --message-id twiceThe second call returns duplicate.
10Set the Profile phone to a local number, 09481234567hello from --from +639481234567Matched to the user (not ignored). The Profile still shows 09481234567.
11AnyhelloREPLY does not have "Reply STOP anytime to stop texts." (only the welcome SMS does).
12AnySTOPopted_out, no REPLY. The SMS switch on Profile is now off.

Every webhook call is also saved in the z_agent_inbound_sms table: the status, reason, routed_via and clarify_candidates columns show what happened. To read the full conversation, open the room in the admin playground at http://localhost:3004/admin/z-agent?tab=playground&room=<roomId>.

Going live

Texts come from +1 (804) 388-6945. Real US delivery waits until Twilio approves the A2P brand and campaign. Until then, mock mode logs each SMS instead of sending it.

  1. Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER=+18043886945. Keep TWILIO_SMS_MOCK=1 until that approval, then set TWILIO_SMS_LIVE=1 and turn mock off.
  2. In Twilio, set the number's incoming-message webhook to https://<app-host>/api/z-agent/sms/inbound and the status callback to https://<app-host>/api/z-agent/sms/status. Add ?secret= when you use the webhook secret.
  3. Text the number from a phone saved on a test account's Profile, and repeat the test cases above.

Replying STOP (or unsubscribe, cancel, end, or quit) turns SMS off. START turns it back on. HELP gets a short reply and is not a chat message.

Where the code lives

PieceFile
Webhook (auth and parsing only)apps/zooly-app/app/api/z-agent/sms/inbound/route.ts
Delivery status callbackapps/zooly-app/app/api/z-agent/sms/status/route.ts
Main flowpackages/z-agent/srv/src/sms-inbound.ts
Routing rules and question textpackages/z-agent/srv/src/sms-thread-routing.ts
Jev classifier, now sharedpackages/util-srv/src/classifyChoice.ts
STOP line and STOP replypackages/z-agent/srv/src/sms-stop.ts
Mock / live sendpackages/twilio-sms
Phone matching (local and international formats)packages/util/src/utils/phone-match.ts
Database columnspackages/db/drizzle/0071_z_agent_inbound_sms_routing.sql