Jesus Vasquez

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.

sms-consent4 min readFrom the Bilingual AI phone receptionist project

  • 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

  1. Caller“Yes, text me the listing.”
  2. ModelLogs the lead (a tool call that works).
  3. ModelSays aloud: “I just sent you a text!”
  4. Send toolNever called.
  5. TwilioNo message created.
  6. Caller’s phoneNothing arrives.

What I changed

  1. 1. Make the send deterministic. The lead-logging result tells the model to send the text right now.
  2. 2. Make success measurable. The send function says “sent” only after the provider confirms.
  3. 3. Forbid the claim. A prompt rule: never say a text was sent unless the tool returned a result this turn.
The failure that made me stop trusting what an agent says about what it did. The confirmation was invented; only the tool's own result can be believed.

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.

noneno record yetpendingasked, no YES yetopted inmay be textedopted outnever textwe ask: “Reply YES”YESconsent captured elsewhere (web form, verbal)STOPSTOPSTOPSTART

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 typedRead asWhy
STOP.STOPPunctuation is ignored
stop pls 🙏STOPA stop word in a short message, emoji dropped
pararSTOPSpanish is built in
SiYESAccents and case are ignored
okOTHERToo ambiguous to count as a confirmation
Can you stop by tomorrow at 3?OTHERToo long to be an opt-out
stop by tomorrowSTOPShort and starts with a stop word: honored, even if it wasn't meant that way
cancel my appointment pleaseSTOPA 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.

  1. Is the message empty?If so: Deny: empty body.
  2. Did they opt out?If so: Deny: only START turns texting back on.
  3. No consent yet, or still waiting for YES?If so: Deny: ask for consent first.
  4. Does it say how to opt out (“Reply STOP”)?If so: Deny: the footer is required.
  5. Is it quiet hours where the recipient is?If so: Deny, with the time it may be retried. No time zone given? Deny too.
  6. All clear: allow the send, then report “sent” only if the provider call really succeeds.
Checks run from the most fundamental to the least, so the reason you get back is the real one. Replies to a message the person just sent (a HELP answer, a STOP confirmation) skip the guard entirely.
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 guard is a pure function: no network, no database. You get a decision and a reason.

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'
From the repository quickstart: opt in, send, STOP, then a blocked send.

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'
From the repository quickstart: opt in, send, STOP, blocked.

Get it

git clone https://github.com/27colab/sms-consent.git
cd sms-consent && npm ci && npm test

Want this built into your product? Get in touch.