Open source · Build an AI front desk, part 2 · MIT
SMS consent, STOP and one send gate
My AI told a caller it had texted them. It hadn't.
- TypeScript
- Twilio
- SQLite
Early (v0.x), written with Claude Code and directed and reviewed by me. Install from npm where published, otherwise clone the repository.
At a glance
v0.1, offline tests; extracted from a consent flow that ran in production
Inside the library
- Consent as a four-state machine per phone number: none, asked, opted in, opted out.
- A reply classifier for English and Spanish that fails toward honouring an opt-out.
- One pure guard function in front of every send: text, opt-out, consent, footer, quiet hours.
- A guarded send that reports sent: true only when the provider actually accepted the message.
- An in-memory store you swap for your database.
You bring
- A Twilio (or other) messaging provider and, in the United States, A2P 10DLC brand and campaign registration. Unregistered traffic gets filtered.
- A messaging service with the number attached, so sends go through the registration.
- A persistent store. Consent must survive a redeploy; the in-memory store is for tests.
- Your provider SDK and delivery tracking. This decides whether to send and records consent; it does not send.
- A lawyer's view on whether your program meets the TCPA and carrier rules. This is a reference implementation, not compliance.
My AI told a caller it had texted them. It hadn't.
Notes on SMS consent for AI agents: a state machine, a single gate, and a rule I learned the hard way: never trust the model to report what it did.
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 the library on this page: 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 |
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.
Run it yourself: sms-consent
What is in the library, what you still have to set up around it, and how to run it offline before you spend a cent.
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 below is how to call it.
const decision = guardSend({ status: 'opted_out' }, 'Sale today!', { timeZone: 'America/New_York' });
// { allow: false, reason: 'opted_out', detail: 'Recipient opted out; only START re-enables messaging.' }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'Get it
git clone https://github.com/27colab/sms-consent.git
cd sms-consent && npm ci && npm testWant this built into your product? Get in touch.