On this page
Key takeaways
- The only trustworthy statement about a send is the provider's own answer. Hand that result back to the model, never its own claim.
- Consent is a state per phone number, not a checkbox. STOP works from anywhere, repeating it changes nothing, and a YES nobody asked for is ignored.
- Put one pure guard in front of every outgoing text. It fails closed, in order, and gives you the real reason.
- People don't type the keyword. Classify replies in English and Spanish, and when a message could be read two ways, STOP wins.
- Consent must survive a deploy. Keep it in persistent storage from the first version, not in a file inside the container.
I was about to hand a project over to the client's new developer when I made one last test call. The agent found a property, asked whether I wanted it by text, and I said yes. "I just sent you a text," it said. I waited. Nothing arrived.
I checked the telephony logs. Not a failed message, not a blocked one. No message at all. The text had never been sent, because the code that sends it had never been called. The agent had logged my lead, skipped the send, and then described, fluently and confidently, an action it hadn't taken.
What happened
- Caller“Yes, text me the listing.”
- ModelLogs the lead (a tool call that works).
- ModelSays aloud: “I just sent you a text!”
- Send toolNever called.
- TwilioNo message created.
- Caller’s phoneNothing arrives.
What I changed
- 1. Make the send deterministic. The lead-logging result tells the model to send the text right now.
- 2. Make success measurable. The send function says “sent” only after the provider confirms.
- 3. Forbid the claim. A prompt rule: never say a text was sent unless the tool returned a result this turn.
The model was a small, fast one, and the instruction to send the text lived only in the prompt. That is exactly the kind of instruction a fast model drops under pressure. Nothing in the system could tell the difference between the model saying a text was sent and a text being sent. So that became the design principle of this library: the only trustworthy statement about a send is the provider's own answer.
Why texting is its own problem
Phoning someone back is easy. Texting them is regulated. In the United States, businesses that text customers register their campaigns with the carriers, and the people they text must have agreed, must be able to stop with a single word, and shouldn't be messaged at 3 a.m. Getting my client approved took real work, and once it was approved, the code had to behave as the registration promised.
The flow I built is a double opt-in. During the call, the caller agrees out loud to get a text. The first text only asks them to reply YES. Only a YES releases the property link. If they ask about several listings before replying, the properties queue up, and one YES delivers all of them. The first version of the code stored this as a pending record per phone number, with a timer so an old question couldn't be answered by a stray YES hours later.
Consent is a state, not a checkbox
Writing it out, consent has four states per phone number: no record, asked and waiting, opted in, and opted out. Everything else is an event that moves between them. Seeing it as a small state machine makes the rules obvious. STOP works from anywhere. Repeating STOP changes nothing. A YES from someone we never asked is ignored, because it isn't an answer to anything.
People don't type the keyword
A text message is not a form field. The same intent arrives as "STOP", "stop pls 🙏", "parar", or with an invisible character in the middle. The classifier lowercases, strips accents and punctuation, drops emoji and zero-width characters, understands English and Spanish, and when a message could be read two ways, STOP wins. It deliberately does not treat "ok" or "sure" as YES, because those are too easy to say about something else.
| What they typed | Read as | Why |
|---|---|---|
| STOP. | STOP | Punctuation is ignored |
| stop pls 🙏 | STOP | A stop word in a short message, emoji dropped |
| parar | STOP | Spanish is built in |
| Si | YES | Accents and case are ignored |
| ok | OTHER | Too ambiguous to count as a confirmation |
| Can you stop by tomorrow at 3? | OTHER | Too long to be an opt-out |
| stop by tomorrow | STOP | Short and starts with a stop word: honored, even if it wasn't meant that way |
| cancel my appointment please | STOP | A known cost: it fails toward honoring an opt-out |
What the classifier does with real-world replies. The last two rows show the price of failing toward the opt-out.
One gate in front of every send
The second half of the design is a single function that every outgoing message passes through, a pure yes-or-no with a reason. It checks, from most to least fundamental: is there any text, did they opt out, do we have consent, does the message tell them how to opt out, and is it quiet hours where they are. It fails closed: no time zone, no send.
- Is the message empty?If so: Deny: empty body.
- Did they opt out?If so: Deny: only START turns texting back on.
- No consent yet, or still waiting for YES?If so: Deny: ask for consent first.
- Does it say how to opt out (“Reply STOP”)?If so: Deny: the footer is required.
- Is it quiet hours where the recipient is?If so: Deny, with the time it may be retried. No time zone given? Deny too.
- All clear: allow the send, then report “sent” only if the provider call really succeeds.
import { guardSend } from "@27colab/sms-consent";
guardSend({ status: "opted_out" }, "Sale today!", { timeZone: "America/New_York" });
// { allow: false, reason: 'opted_out',
// detail: 'Recipient opted out; only START re-enables messaging.' }
The gate has a second job, and it's the one that started this story. The guarded send function calls your provider only if the guard allows it, and it returns sent: true only if the provider call actually resolved. If the provider throws, you get a failure with a reason, not a success. That return value is what you hand back to the model, so the model's next sentence is based on the truth.
await handleInbound({ store, accountId, from: phone, body: "STOP", replies }); // opted_in -> opted_out
const blocked = await sendGuarded({ ...base, body: "Sale today! Reply STOP to opt out." });
console.log(blocked.sent, !blocked.sent && blocked.reason); // false 'opted_out'
Three smaller lessons
- Consent must survive a deploy. My first version kept it in a file inside the container, so every redeploy forgot who had said YES, and worse, who had said STOP. It now lives on persistent storage, and in the library behind a store interface you choose: in memory for tests, a database in production.
- Don't text what you can't link. If a listing had no public page, the agent shouldn't offer a text at all. A promise you can't keep is a worse experience than no offer.
- Route through the registration. Carrier approval attaches to a messaging service, not just a phone number, so sending through the service is the reliable path once the number is attached.
What this is not
- It is a reference implementation, not legal advice and not a compliance guarantee. Whether your program meets the TCPA, carrier rules or anything else depends on your content, your consent collection and your registration.
- Your phone provider may already intercept STOP, START and HELP on its own. This keeps your ledger in step with what recipients actually sent.
- It decides whether to send and records consent. It is not a provider SDK and it doesn't track delivery.
I built this with Claude as my pair, in the browser chat and later in Claude Code. I wrote the rules, tested against real calls, and decided what shipped.
What's in the library
Consent as a state machine, a multilingual reply classifier, quiet hours and a single send guard, all with an in-memory store you can swap. Everything runs offline. The story above explains why each rule exists; the code is how to call it.
You bring a Twilio (or other) messaging provider and, in the United States, A2P 10DLC brand and campaign registration; a messaging service with the number attached; a persistent store; your provider SDK and delivery tracking; and a lawyer's view on whether your program meets the TCPA and carrier rules.
Get it
MIT-licensed, early (v0.1), extracted from a consent flow that ran in production. Written with Claude Code and directed and reviewed by me.
git clone https://github.com/27colab/sms-consent.git
cd sms-consent && npm ci && npm test
Who wrote this
Jesus Vasquez · Engineering · fractional CTO
Builds it: WordPress and Next.js, integrations and AI agents, with the tests, analytics and accessibility that keep it honest.