Guide10 min read

How to Build an Agentic Appointment Reminder & No-Show Recovery Agent

Build a two-way SMS agent that texts appointment reminders, reads replies, and reschedules no-shows automatically — on a real US number your agent legally can't fake. Full build with Naive's phone, identity, and LLM primitives, with execution-time permission enforcement shown at every step.

Read the docs →

/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
Guide
TL;DR
  • 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.

Carrier-registered SMS with replies classified through the OpenRouter wrapper

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.

Appointment reminder agent request path

Setup

Install the SDK and create a client with your company secret key:

npm install @usenaive-sdk/server
import { 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 = false turns the whole primitive off — every phone call for that location returns forbidden with primitive_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 === true

Once 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/:agentId

Step 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:

Four walls a stolen API key still cannot cross

  • Primitive gate. With phone.enabled = false on the kit, every phone call for that location returns forbidden / 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 PendingApproval and 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 after unassign, is refused with forbidden. Numbers are identity, scoped per agent.
  • Carrier compliance gate. Outbound stays blocked until the 10DLC campaign is active; premature sends return compliance_pending with 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 denials

What 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 llm primitive 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.

Frequently Asked Questions
Why can't I just use Twilio or a spare phone for agent SMS reminders?+
US carriers require an approved 10DLC (A2P) campaign before any number can send application-to-person SMS, and campaign registration requires a real, KYC-verified business entity with an EIN. A raw Twilio number with no registered campaign gets its messages silently filtered or blocked. A personal cell is a compliance and privacy problem and can't be scoped per agent or per location. Naive provisions carrier-registered numbers against a formed business the agent brings up itself, so compliant outbound is much more likely to reach recipients.
What does the agent need before it can provision a number?+
A completed LLC formation and the company's EIN. The chain is verification.start (KYC every member) -> formation.submit then execute (files the LLC, issues the EIN) -> phone.provision({ ein }) (buys the number and registers the 10DLC campaign). The EIN is required on the first provision because it registers the carrier brand; later numbers reuse it.
How is provisioning kept safe from a runaway or compromised agent?+
phone.provision is approval-gated by default in the Account Kit — an agent (API-key) call returns a PendingApproval (HTTP 202) instead of buying a number, and a human approves it via the approvals queue before anything is charged or registered. You can also disable the phone primitive entirely per kit, in which case every phone call returns forbidden with primitive_disabled_by_kit.
Why does the first reminder fail with compliance_pending?+
Outbound SMS is carrier-gated, not approval-gated. Until the number's 10DLC campaign flips to active (typically 1-2 business days, automatic), phone.send returns compliance_pending — no message is sent and no credit is charged. Inbound SMS works immediately, so you can collect replies while outbound is still unlocking. Check progress with phone.status().
How does the agent understand free-text replies like 'can we do Friday instead'?+
Read the inbound message with phone.messages / phone.read, then pass it to naive.llm.chat with an OpenRouter response_format of type json_schema. The model returns a strict object like { intent: 'reschedule', requested_time: 'Friday afternoon' } that your code branches on to confirm, offer a new slot, or opt the patient out on STOP.
How do I run this across many clinic locations?+
Each location is a tenant user. Call naive.forUser(location.id) for every phone, LLM, and identity call — each location gets its own number, its own inbound inbox, and its own scoped agent, isolated from the others and metered on one company credit balance. Assign users to an Account Kit to apply the same phone policy (gating + approvals) to all of them at once.
What does it cost to run?+
Phone credits are roughly: ~44 for the first number (one-time carrier registration + first month), ~4 for each additional number and per monthly rental, and ~0.04 per outbound SMS segment; inbound is free. LLM classification is billed from OpenRouter's returned usage cost. One-time setup is $249 for LLC formation; KYC is free. Credits are $0.05 each with 20 free on signup. See usenaive.ai/pricing for current rates.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading