- ›
No-shows cost service businesses 10-30% of booked revenue; SMS reminders are the single highest-ROI fix— but an AI agent can't legally send them without a registered business identity. - ›US carriers block application-to-person (A2P) SMS from any number without an approved 10DLC campaign, and 10DLC registration requires a KYC-verified business with an EIN. That identity chain is the moat.
- ›Naive gives the agent the whole chain in a few calls: verification.start (KYC) -> formation.submit (LLC + EIN) -> phone.provision (number + 10DLC campaign).
- ›
Provisioning is approval-gated, the number is scoped to one agent with send_sms, and outbound stays carrier-gated until the campaign is active— four execution-time walls a leaked key can't cross. - ›Inbound replies are read back and classified with naive.llm.chat (OpenRouter wrapper, structured JSON output), then the agent confirms, reschedules, or opts the patient out.
- ›
Every location is a tenant user via forUser(id) with its own number and inbox— one credit balance, full per-tenant isolation.
No-shows are the quiet tax on every appointment-based business. Clinics, dental offices, salons, law firms, and repair shops lose an estimated 10-30% of booked revenue to patients and clients who simply forget. The single highest-ROI fix is boring and well known: text a reminder, read the reply, and rebook the ones who can't make it.
The hard part is not the logic. The hard part is that an AI agent cannot legally send that first text.
- US carriers block application-to-person (A2P) SMS from any number that isn't tied to an approved 10DLC campaign.
- A 10DLC campaign can only be registered against a real business entity with an EIN — which requires KYC on the people behind it.
- So the agent needs a verified identity, a formed company, a tax ID, and a carrier-registered number before it sends a single character.
That identity chain is exactly what Naive hands an agent in a few calls. This guide builds the whole thing end to end: a two-way SMS agent that texts reminders, reads replies, reschedules no-shows, and does it all on a number it provisioned against a business it formed — with permission checks enforced at execution time, not asserted in a prompt.
For multi-tenant isolation and approval gates, see Building AI Agents Into Your SaaS and How to add human approval.
Why this can't run on a raw Twilio number
The status quo for "agent that texts customers" falls apart on compliance, not code:
- A spare Twilio/Surge number with no campaign — messages are silently filtered or dropped by carrier A2P enforcement. You think you sent 500 reminders; the carrier delivered a handful.
- A personal cell — a privacy and compliance disaster, and impossible to scope per agent or per location.
- A shared team number — collapses every location and every patient into one identity, so one person's reply (and one person's one-time codes) land in everyone's inbox.
- Building it yourself — you now own the sub-account model, 10DLC brand registration, per-tenant routing, inbound storage, billing, and revocation. Months before the first reminder ships.
The reason it stays hard is that a phone number is identity. To send legitimately, the entity behind the number has to be real and verified. Naive makes the agent bring up that entity itself, then binds the number to it — so delivery works and every action is governed.
Setup
Install the SDK and create a client with your company secret key:
npm install @usenaive-sdk/serverimport { Naive, isPendingApproval, NaiveError } from "@usenaive-sdk/server";
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });Each clinic location is a tenant user. Create one, and gate its capabilities with an Account Kit so the phone primitive is enabled but provisioning still requires a human to approve:
// One policy template, applied to every location
const kit = await naive.accountKits.create({
name: "Clinic Location",
primitives_config: {
verification: { enabled: true },
formation: { enabled: true },
phone: { enabled: true, requiresApproval: true }, // provisioning is gated
llm: { enabled: true },
logs: { enabled: true },
},
});
// A location = a tenant user, assigned to the kit
const northgate = await naive.users.create({
external_id: "loc_northgate",
email: "northgate@brightsmile.example",
label: "BrightSmile - Northgate",
account_kit_id: kit.id,
});Everything the agent does for this location goes through naive.forUser(northgate.id), which resolves the caller against that location's kit on every request.
The Account Kit is the outermost wall.
primitives_config.phone.enabled = falseturns the whole primitive off — every phone call for that location returnsforbiddenwithprimitive_disabled_by_kit, no matter what the agent tries.
Step 1: Give the agent a business identity
A number is registered against a formed company. So before any phone call, the agent runs the identity chain. Both of these steps are approval-gated by default (they cost money and create legal records), so an agent call resolves to either the result or a PendingApproval.
Start KYC for the owner(s):
const loc = naive.forUser(northgate.id);
const kyc = await loc.verification.start({
members: [
{
first_name: "Alice",
last_name: "Nguyen",
email: "alice@brightsmile.example",
ownership_percentage: 100,
role: "primary",
is_responsible_party: true,
},
],
});
// kyc.primary_link -> the owner completes KYC in the browser
// Poll until ready: GET verification -> ready_for_formation === trueOnce ready_for_formation is true, form the LLC. Pick an industry code from GET /v1/formation/naics-codes (or naive formation naics-codes), then submit. formation.submit returns a $249 hosted checkout; after payment you execute the filing, which issues the EIN:
const formation = await loc.formation.submit({
verification_id: kyc.id,
entity_type: "LLC",
state: "WY",
naics_code_id: naicsCodeId, // from GET /v1/formation/naics-codes
description: "Dental practice appointment operations",
name_options: [{ name: "BrightSmile Northgate", entity_type_ending: "LLC" }],
});
// formation.checkout_url -> owner pays $249
// After payment, execute the filing (issues the EIN):
// naive formation execute <formation.id> (POST /v1/formation/:id/submit)At the end of this step the location has a verified responsible party, a filed LLC, and an EIN. That EIN is the thing that unlocks a carrier brand — the piece an agent has no other way to obtain.
Step 2: Provision a carrier-registered number
Now the agent can buy a number. phone.provision buys a US number and registers the 10DLC campaign in one call, using the formed-business details and EIN. Because it spends credits and registers a carrier brand, it is approval-gated — the agent call returns a PendingApproval until a human approves it:
const res = await loc.phone.provision({
ein: "12-3456789", // required on the first number: registers the carrier brand
area_code: "206",
label: "Northgate reminders",
});
if (isPendingApproval(res)) {
// Agent is frozen here — a human approves in the dashboard or via the API
console.log("Awaiting approval:", res.approval_id);
await loc.approvals.approve(res.approval_id); // replays the frozen action
}After approval, the number is live. Inbound SMS works immediately; outbound is still gated behind carrier campaign approval:
{
"phone": {
"id": "phone-uuid",
"e164": "+12065550100",
"capabilities": { "voice": false, "sms_inbound": true, "sms_outbound": false, "mms": false },
"status": "active"
},
"campaign": { "id": "campaign-uuid", "status": "created", "eta": "1-2 business days" }
}Scope the number to one agent
A number is a company resource; agents connect to it with explicit permissions. Give the reminder agent send_sms and receive_sms — and nothing else can send from this number:
// reminderAgent.id comes from the agent profile you provisioned for this
// location — see /docs/getting-started/agent-profiles
await loc.phone.assign(phoneId, reminderAgent.id, ["send_sms", "receive_sms"]);
// See who's connected: naive phone assignments <phone-id>An agent without send_sms on the number is refused with forbidden when it tries to send. Revocation is instant — disconnect the agent and future sends are refused immediately:
naive phone unassign <phone-id> <agent-id> # DELETE /v1/phone/:id/assign/:agentIdStep 3: Send the reminder (and handle the carrier gate)
Sending is a routine action — not approval-gated. But it is carrier-gated: until the campaign is active, phone.send returns compliance_pending, and no message is sent and no credit is charged. Write the send to expect that and let it unlock on its own:
async function sendReminder(phoneId: string, to: string, when: string) {
try {
return await loc.phone.send({
from: phoneId,
to,
body: `Reminder: your cleaning is ${when}. Reply C to confirm, R to reschedule, STOP to opt out.`,
});
} catch (err) {
if (err instanceof NaiveError && err.code === "compliance_pending") {
// Campaign not approved yet — requeue; it unlocks automatically
return { deferred: true };
}
throw err;
}
}
// Check the carrier-registration pipeline any time
const status = await loc.phone.status();Once the campaign flips to active (automatic, via a carrier webhook), the same call sends for real and bills ~0.04 credits per 160-character segment.
Step 4: Read replies and act on them
Inbound is where the two-way loop lives — and it works from the moment the number is provisioned, so you can start collecting confirmations before outbound even unlocks. List and read the messages that came back:
const { messages } = await loc.phone.messages(phoneId, { limit: 50 });
for (const m of messages) {
const full = await loc.phone.read(m.id); // full body + any media
await handleReply(full.from, full.body);
}Free-text replies ("can we do Friday afternoon instead?") need interpretation. Naive's llm primitive is a full wrapper over OpenRouter, so you get OpenRouter's structured-output support directly — pass a response_format of type json_schema and the model returns a strict, typed object:
async function classifyReply(body: string) {
const res = await loc.llm.chat({
model: "anthropic/claude-sonnet-4.6",
models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"], // OpenRouter fallback chain
messages: [
{
role: "system",
content:
"Classify a patient's SMS reply to an appointment reminder. " +
"requested_time is free text or null.",
},
{ role: "user", content: body },
],
response_format: {
type: "json_schema",
json_schema: {
name: "reply_intent",
strict: true,
schema: {
type: "object",
properties: {
intent: {
type: "string",
enum: ["confirm", "reschedule", "cancel", "opt_out", "unclear"],
},
requested_time: { type: ["string", "null"] },
},
required: ["intent", "requested_time"],
additionalProperties: false,
},
},
},
});
return JSON.parse(res.choices[0].message.content) as {
intent: "confirm" | "reschedule" | "cancel" | "opt_out" | "unclear";
requested_time: string | null;
};
}Branch on the intent — and reply on the same scoped number:
async function handleReply(from: string, body: string) {
const { intent, requested_time } = await classifyReply(body);
switch (intent) {
case "confirm":
await markConfirmed(from);
break;
case "reschedule": {
const slot = await nextOpenSlot(requested_time); // your scheduling system
await sendReminder(phoneId, from, slot); // offer the new time
break;
}
case "cancel":
await releaseSlot(from);
break;
case "opt_out":
await suppress(from); // never text this number again
break;
case "unclear":
await escalateToStaff(from, body);
break;
}
}That is the full arc: the agent formed a company, provisioned a compliant number, sent a reminder, read the reply, and rebooked the no-show — no human in the loop except the one approval on the number.
The moat: four walls enforced at execution time
None of the controls above are prompt instructions or code-review conventions. They are enforced by the runtime on every call, so a leaked API key or a jailbroken agent still hits all four:
- Primitive gate. With
phone.enabled = falseon the kit, every phone call for that location returnsforbidden/primitive_disabled_by_kit. The agent can't reach a carrier at all. - Approval on provision. Buying a number and registering a brand is gated — the agent gets a
PendingApprovaland a human approves before money moves or a carrier record is created. - Per-agent scope. The number is bound to one agent with
send_sms. Any other agent, or the same agent afterunassign, is refused withforbidden. Numbers are identity, scoped per agent. - Carrier compliance gate. Outbound stays blocked until the 10DLC campaign is
active; premature sends returncompliance_pendingwith no message and no charge.
Here is the third wall failing closed — a support agent with no send_sms grant trying to send:
try {
await naive.forUser(northgate.id).phone.send({ from: phoneId, to, body });
} catch (err) {
if (err instanceof NaiveError && err.code === "forbidden") {
// "agent lacks send_sms on this number" — refused at execution time
}
}Scale to every location
Because each location is a tenant user, going from one clinic to fifty is the same code with a different forUser(id):
for (const location of locations) {
const loc = naive.forUser(location.id);
const { messages } = await loc.phone.messages(location.phoneId, { limit: 50 });
for (const m of messages) await handleReply(location.phoneId, (await loc.phone.read(m.id)).body);
}- Every location has its own number and its own inbound inbox — a reply to Northgate never surfaces at Southgate.
- All of them inherit the same policy from the shared Account Kit; tighten approvals once and it applies everywhere.
- Everything meters onto one company credit balance.
Audit every message
Because permission checks run at execution time, they produce an event trail. Pull the per-location activity log for compliance or debugging:
const { events } = await naive.forUser(northgate.id).logs.query({ limit: 100 });
// includes phone.provision, approvals, phone.send, and forbidden/compliance_pending denialsWhat you built
- A two-way SMS reminder + no-show recovery agent running on a carrier-registered number it provisioned itself.
- A verified business identity chain (KYC -> LLC -> EIN -> 10DLC) that supports compliant outbound delivery — the part an agent cannot fake.
- Four execution-time walls (kit gate, approval, per-agent scope, carrier gate) enforced by the runtime, not by trust.
- Free-text reply understanding via the OpenRouter-backed
llmprimitive with strict structured output. - Per-location isolation that scales from one clinic to a chain with a single
forUser(id)swap.
Get started
Drop this starter prompt into any coding agent to wire up Naive:
Read https://usenaive.ai/skill.md and use it to set up Naive in my project.
- Phone primitive: usenaive.ai/docs/getting-started/phone
- Prerequisite - form the company: usenaive.ai/docs/getting-started/formation and identity verification
- Governance & approvals: usenaive.ai/docs/getting-started/approvals and account kits
- LLM (OpenRouter wrapper): usenaive.ai/docs/getting-started/llm
- Background reading: Twilio 10DLC overview, The Campaign Registry, and Surge
- Quickstart: usenaive.ai/docs/getting-started/quickstart
- Join the community on Discord
Why can't I just use Twilio or a spare phone for agent SMS reminders?+
What does the agent need before it can provision a number?+
How is provisioning kept safe from a runaway or compromised agent?+
Why does the first reminder fail with compliance_pending?+
How does the agent understand free-text replies like 'can we do Friday instead'?+
How do I run this across many clinic locations?+
What does it cost to run?+
Building the autonomous company infrastructure.
Building multi-tenant AI agents into your SaaS means giving each customer an isolated, governed agent. Here's the full architecture and how to ship it.
How to add human approval to an AI agent: gate sensitive actions on your Account Kit, surface pending approvals, and replay on approve. A step-by-step guide.
Provision a real US phone number for any AI Employee — send and receive SMS — with carrier (10DLC) registration handled at provisioning. Inbound works immediately; outbound unlocks automatically on campaign approval.
Human-in-the-loop approvals that freeze sensitive actions until you say yes, a per-user audit trail of everything an agent did, short-lived scoped MCP sessions, and unified async job tracking — the controls that make an autonomous fleet safe to run.