Jesus Vasquez
Hire me

Open source · MIT · Build an AI front desk, part 2

sms-consent

Texting: consent, STOP and never a phantom send. The unglamorous rules that decide whether your agent may send a text: who agreed, who opted out, what the footer says, and what hour it is for them.

  • TypeScript
  • Twilio
  • SQLite

Early (v0.x), written with Claude Code and directed and reviewed by me. Not on npm yet: clone the repository to use it.

Once your agent can answer calls, the next feature everyone wants is "text me the details". Sending a text is easy. Knowing whether you are allowed to is where projects go wrong, and where an AI agent makes it worse, because a model will happily say "I've texted you" even when nothing was sent.

  1. Customer replies
  2. Keyword classifier
  3. Consent state machine
  4. Send guard
  5. Provider call

Consent is a state, not a checkbox

Each phone number, per account, is in one of four states: none, pending (you asked, they have not said YES), opted in, or opted out. STOP always moves to opted out, and repeating it changes nothing. START brings an opted-out person back. A YES only counts if you actually asked.

Read replies the way people write them

Customers do not type STOP in capitals. They write "stop pls 🙏", "parar", "STOP." The classifier ignores case, accents, punctuation, emoji and zero-width characters, understands English and Spanish, and when a message could be read two ways, STOP wins.

One decision point

Before every outbound message, one function answers allow or deny, with a reason. It checks, in order of severity: opted out, no consent or still pending, a missing "Reply STOP" footer, and quiet hours in the recipient's time zone (it refuses if you do not give it one).

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

Never a phantom send

The guarded send reports success only when the guard allowed the message and your provider call actually resolved. That matters most when an LLM is in the loop: the text the model sees is the truth about what happened.

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.

Try to break it

Pretend the customer already agreed. Have the business text them, then unsubscribe in your own words, then try to send anyway, without the footer, or at 10:30 pm.

SMS consent: try to unsubscribe

Pretend you already agreed to texts from a business. Reply the way real people do (“stop”, “STOP pls 🙏”, “parar”), then see what the business side can still send. This runs the real sms-consent (opens in a new tab) library in your browser.

27 Colab

No messages yet. Tap the button below to have the business text you.

Behind the scenes

Consent
Opted in
Your last reply read as
nothing yet
Guard decision
nothing sent yet

Now play the business

Try to send anyway, including the ways that break the rules.

In real life a customer opts in first (a web form, or by texting a keyword to you). Here we skip that step. A reference implementation, not legal advice. Nothing is sent anywhere.

What this does not do

  • It is a reference implementation, not legal advice and not a compliance guarantee.
  • US business texting also needs carrier registration (10DLC), which is outside this library.
  • "ok" and "sure" are deliberately not treated as YES: too ambiguous to count as confirmation.

Get it

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

Used in

Want this built into your product? Get in touch.