Skip to content

All posts

Build an AI front desk, part 2 · 5 min read

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.

By Jesus Vasquez · Updated

Source code: github.com/27colab/sms-consent · Documentation

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

  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 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.

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.

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.

Sources

  1. github.com/27colab/sms-consent
  2. docs.27colab.com/packages/sms-consent/
  3. theuxui.dev/open-source/sms-consent/
  4. twilio.com/docs/messaging/compliance/a2p-10dlc

Want this done for your company?

Tell us what’s going on. You get a written brief back, and both of us read it.

Talk to us about an AI front desk

We’d like to measure how people use this site with Google Analytics, Microsoft Clarity, and our own visit log, and whether our ads lead to calls. Nothing loads unless you allow it. Privacy policy