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.
- Customer replies
- Keyword classifier
- Consent state machine
- Send guard
- 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'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 testUsed in
Want this built into your product? Get in touch.