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).
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.
When a text arrives, we check these in order and stop at the first match:
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.
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.
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.
There is no real number yet, so we fake both directions:
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 ….apps/zooly-app/.env.local add:
TWILIO_SMS_MOCK=1
Z_AGENT_SMS_INBOUND_SECRET=any-long-random-string
npm run db:migrate
in packages/db. On Windows without bash, run
npx tsx --env-file=../../.env.local src/migrate.ts there instead.npm run dev in apps/zooly-app, port 3004).tests/manual/auth-login.md).+15550001111.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).
| # | Setup | Send | Pass when |
|---|---|---|---|
| 1 | User has no brand conversations, and has opened their agent chat once (that creates it) | hello | processed, routedVia: home. The reply appears in the user's personal chat on the web too. |
| 2 | Create one brand conversation (Conversations + → "Bring a conversation to Z", e.g. "Nike wants a summer shoot for $5k") | sounds good | processed, routedVia: single. The message and reply appear in the Nike conversation on the web. |
| 3 | Add 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. |
| 4 | Same two brands | Can I raise my rate to $5k for everything? | processed, routedVia: jev, in the personal chat. |
| 5 | Same two brands | ok | awaiting_choice. REPLY is "Which conversation is this for? Reply 1 for …, 2 for …, or 3 for your own agent." |
| 6 | Right after test 5 | 2 | processed, routedVia: choice. The original "ok" shows up in the conversation numbered 2. |
| 7 | Same two brands | Great, thanks | Not ignored. It is routed by Jev or gets the question, never "code not matched". |
| 8 | Any | hello from an unknown number (--from +15559999999) | ignored, reason no conversations for phone. |
| 9 | Any | Send the same --message-id twice | The second call returns duplicate. |
| 10 | Set the Profile phone to a local number, 09481234567 | hello from --from +639481234567 | Matched to the user (not ignored). The Profile still shows 09481234567. |
| 11 | Any | hello | REPLY does not have "Reply STOP anytime to stop texts." (only the welcome SMS does). |
| 12 | Any | STOP | opted_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>.
Tests 3 and 4 ask the real classifier, so they need AI_GATEWAY_API_KEY in
.env.local. Without it, the Gemini fallback answers. If both are
unavailable, the user always gets the numbered question.
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.
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.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.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.
| Piece | File |
|---|---|
| Webhook (auth and parsing only) | apps/zooly-app/app/api/z-agent/sms/inbound/route.ts |
| Delivery status callback | apps/zooly-app/app/api/z-agent/sms/status/route.ts |
| Main flow | packages/z-agent/srv/src/sms-inbound.ts |
| Routing rules and question text | packages/z-agent/srv/src/sms-thread-routing.ts |
| Jev classifier, now shared | packages/util-srv/src/classifyChoice.ts |
| STOP line and STOP reply | packages/z-agent/srv/src/sms-stop.ts |
| Mock / live send | packages/twilio-sms |
| Phone matching (local and international formats) | packages/util/src/utils/phone-match.ts |
| Database columns | packages/db/drizzle/0071_z_agent_inbound_sms_routing.sql |