Build a Dental AI Receptionist: A Vapi Developer Guide
Learn how to build a dental AI receptionist with Vapi, ElevenLabs, and Cloudflare Workers, including real PMS integration code and production gotchas.
Dental offices lose real money to hold music. A 2026 Dental Economics review of the DIY AI receptionist trend found that practices routinely miss 15 to 20 calls a week after hours and during lunch, and each missed new-patient call is worth $200 to $400 in lifetime value depending on the procedure mix. If you're a developer who's been asked to build a dental AI receptionist for a practice — whether as an in-house project or a client engagement — this guide walks through the actual stack, the actual code, and the actual failure modes, not the marketing version.
We'll build a voice agent that answers the phone, checks real appointment availability against a practice management system (PMS), books or reschedules the appointment, and texts a confirmation — all inside the response-time budget a phone call actually allows. The stack is Vapi for call orchestration, ElevenLabs for text-to-speech, and Cloudflare Workers as the webhook backend that talks to the PMS.
What you're actually building
A dental AI receptionist is not a chatbot with a phone number attached. It's three systems stitched together in real time: a speech pipeline (speech-to-text, an LLM, text-to-speech), a tool-calling layer that reaches into the practice's scheduling data, and a PMS integration that has to treat appointment data as protected health information (PHI) from the moment it leaves the phone call.
Caller (PSTN)
|
v
Telephony carrier (Twilio / Vapi-native SIP trunk)
|
v
Vapi (STT -> LLM -> TTS orchestration)
| \
| -> ElevenLabs (streaming TTS, ~300ms to first audio)
v
Vapi custom tool call (HTTPS webhook, JSON)
|
v
Cloudflare Worker (tool router, PHI-aware logging, retries)
| \
| -> D1 / KV (call state, idempotency keys)
v
Practice Management System API
(Open Dental REST API, or Dentrix/Eaglesoft via
Henry Schein One API Exchange)
|
v
Confirmation SMS (Twilio) back to patient
Every arrow in that diagram is a place a call can fail silently, which is why the Production gotchas section below is not optional reading.
Prerequisites and stack decisions
Voice layer: Vapi plus ElevenLabs
Vapi handles the call lifecycle — answering, transcription, turn-taking, and dispatching tool calls to your backend. ElevenLabs supplies the voice; its streaming endpoint starts returning audio in roughly 300ms, which matters because anything slower reads as an awkward pause to a caller who's used to a human picking up. Vapi's own tool-call budget is tight: the platform gives your webhook about 10 seconds to return a first response, the telephony carrier hangs up the whole exchange around 15 seconds if nothing comes back, and Vapi reserves roughly 7.5 of those seconds just for call setup before your code ever runs. Design for that budget from day one — it shapes every decision below.
Backend: Cloudflare Workers
Workers are a reasonable default for the tool-call backend: cold starts are near-zero, and you get a global edge network so the PMS API call — often the slowest hop — isn't waiting on a webhook server three timezones away. The catch is Workers cap CPU time at 30 seconds on paid plans (10ms on the free tier for anything beyond simple requests), so this is not the place to run a synchronous batch reconciliation job. Keep the Worker's job narrow: validate the tool call, hit the PMS, format a response, log it, done.
Practice management system access
Open Dental is the easiest PMS to integrate against for a first build — it publishes a documented REST API with key-based authentication and endpoints for appointments, patients, and provider schedules. Dentrix and Eaglesoft are both owned by Henry Schein One and require going through their API Exchange partner program, which involves an approval process and a signed data-use agreement before you get credentials. Budget two to four weeks for that approval if the practice runs Dentrix — it is the single biggest schedule risk in this whole project, bigger than any of the code below.
Step 1: Create the Vapi assistant
Assistants are configured once via the API, not hand-edited in a dashboard every time you change a prompt. Store the config in your repo so it's versioned like everything else.
// create-assistant.ts
// Run with: npx tsx create-assistant.ts
const VAPI_API_KEY = process.env.VAPI_API_KEY!;
const WORKER_BASE_URL = "https://dental-receptionist.yourdomain.workers.dev";
async function createAssistant() {
const res = await fetch("https://api.vapi.ai/assistant", {
method: "POST",
headers: {
Authorization: `Bearer ${VAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Bend Family Dental Receptionist",
model: {
provider: "anthropic",
model: "claude-sonnet-5",
temperature: 0.3,
systemPrompt: [
"You are the phone receptionist for Bend Family Dental.",
"Confirm the caller's name, reason for the visit, and preferred",
"day/time before calling any tool. Never invent availability —",
"always call check_availability first. If a caller asks about",
"cost, insurance, or clinical questions outside scheduling,",
"offer to have the office call them back.",
].join(" "),
},
voice: {
provider: "11labs",
voiceId: "rachel",
model: "eleven_turbo_v2_5",
},
firstMessage:
"Thanks for calling Bend Family Dental, this is Riley. How can I help you today?",
serverUrl: `${WORKER_BASE_URL}/vapi/tool-call`,
serverUrlSecret: process.env.VAPI_WEBHOOK_SECRET,
tools: [
{
type: "function",
function: {
name: "check_availability",
description:
"Check open appointment slots for a given date range and visit type.",
parameters: {
type: "object",
properties: {
visitType: {
type: "string",
enum: ["cleaning", "new-patient-exam", "emergency", "consult"],
},
earliestDate: { type: "string", description: "YYYY-MM-DD" },
latestDate: { type: "string", description: "YYYY-MM-DD" },
},
required: ["visitType", "earliestDate"],
},
},
},
{
type: "function",
function: {
name: "book_appointment",
description: "Book a confirmed appointment slot for a patient.",
parameters: {
type: "object",
properties: {
slotId: { type: "string" },
patientName: { type: "string" },
patientPhone: { type: "string" },
isNewPatient: { type: "boolean" },
},
required: ["slotId", "patientName", "patientPhone"],
},
},
},
],
}),
});
if (!res.ok) {
throw new Error(`Vapi assistant creation failed: ${res.status} ${await res.text()}`);
}
const assistant = await res.json();
console.log("Created assistant:", assistant.id);
}
createAssistant().catch(console.error);
Note the model choice: Claude handles the conversational reasoning (deciding when a caller has given enough information to check availability) while ElevenLabs handles only the voice. Keeping those concerns separate makes each one easier to debug when a call goes wrong.
Step 2: Route tool calls from a Cloudflare Worker
This is the part most tutorials skip: Vapi's tool-call webhook contract is unforgiving. Your Worker must always return HTTP 200 — any other status code is treated as a hard failure and the call degrades — and the response body must echo back the exact toolCallId with a result or error field that is a string, not an object.
// worker/index.ts
export interface Env {
OPEN_DENTAL_API_KEY: string;
OPEN_DENTAL_BASE_URL: string;
VAPI_WEBHOOK_SECRET: string;
CALL_STATE: KVNamespace;
}
interface VapiToolCall {
id: string;
function: { name: string; arguments: Record };
}
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
if (url.pathname !== "/vapi/tool-call" || request.method !== "POST") {
return new Response("Not found", { status: 404 });
}
const secret = request.headers.get("x-vapi-secret");
if (secret !== env.VAPI_WEBHOOK_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const payload = await request.json<{ message: { toolCalls: VapiToolCall[] } }>();
const toolCalls = payload.message?.toolCalls ?? [];
const results = await Promise.all(
toolCalls.map((call) => handleToolCall(call, env))
);
// Vapi requires HTTP 200 on every response, success or failure.
return Response.json({ results });
},
};
async function handleToolCall(call: VapiToolCall, env: Env) {
try {
switch (call.function.name) {
case "check_availability":
return await checkAvailability(call, env);
case "book_appointment":
return await bookAppointment(call, env);
default:
return { toolCallId: call.id, error: `Unknown tool: ${call.function.name}` };
}
} catch (err) {
// Never let an exception escape — a thrown error here would still
// need to come back as a 200 with an error string, or Vapi stalls
// until its own timeout fires.
return { toolCallId: call.id, error: `Tool execution failed: ${String(err)}` };
}
}
async function checkAvailability(call: VapiToolCall, env: Env) {
const { visitType, earliestDate, latestDate } = call.function.arguments as {
visitType: string;
earliestDate: string;
latestDate?: string;
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 6000); // leave headroom in the ~10s budget
try {
const res = await fetch(
`${env.OPEN_DENTAL_BASE_URL}/appointments/slots?type=${encodeURIComponent(
visitType
)}&from=${earliestDate}&to=${latestDate ?? earliestDate}`,
{
headers: { Authorization: `ODFHIR ${env.OPEN_DENTAL_API_KEY}` },
signal: controller.signal,
}
);
if (!res.ok) {
return { toolCallId: call.id, error: `PMS returned ${res.status}` };
}
const slots: Array<{ id: string; start: string }> = await res.json();
const summary = slots
.slice(0, 3)
.map((s) => `${s.id}: ${s.start}`)
.join("; ");
return {
toolCallId: call.id,
result: slots.length ? `Available slots: ${summary}` : "No open slots in that range.",
};
} finally {
clearTimeout(timeout);
}
}
async function bookAppointment(call: VapiToolCall, env: Env) {
const { slotId, patientName, patientPhone, isNewPatient } = call.function.arguments as {
slotId: string;
patientName: string;
patientPhone: string;
isNewPatient?: boolean;
};
// Idempotency key prevents a duplicate booking if Vapi retries the tool call
// after a slow response — see "Production gotchas" below.
const idempotencyKey = `book:${slotId}:${patientPhone}`;
const already = await env.CALL_STATE.get(idempotencyKey);
if (already) {
return { toolCallId: call.id, result: `Already booked: confirmation ${already}` };
}
const res = await fetch(`${env.OPEN_DENTAL_BASE_URL}/appointments/${slotId}/book`, {
method: "PATCH",
headers: {
Authorization: `ODFHIR ${env.OPEN_DENTAL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ patientName, patientPhone, isNewPatient: !!isNewPatient }),
});
if (!res.ok) {
return { toolCallId: call.id, error: `Booking failed: PMS returned ${res.status}` };
}
const confirmation = await res.json<{ confirmationCode: string }>();
await env.CALL_STATE.put(idempotencyKey, confirmation.confirmationCode, {
expirationTtl: 3600,
});
return {
toolCallId: call.id,
result: `Booked. Confirmation code ${confirmation.confirmationCode}.`,
};
}
Step 3: Send the confirmation and log the call safely
Once book_appointment returns a confirmation code, fire an outbound SMS in the same Worker request (or a queued follow-up if you want to keep the tool-call response fast) and write a call record. The call record is where teams get sloppy: a transcript of a scheduling call contains a patient's name, phone number, and appointment reason, which is PHI the moment it's tied to a real patient. Store transcripts in a system covered by a signed business associate agreement (BAA) — with Vapi, ElevenLabs, and Cloudflare — or redact patient identifiers before they hit a general-purpose logging pipeline.
If you haven't set up BAAs yet, don't ship this to production — read through our HIPAA compliance guide for dental voice agents and walk through what a compliant setup looks like end to end before you touch a real PMS credential.
Production gotchas
The timeout budget is tighter than it looks
A slow PMS API response doesn't just make one tool call slow — it can make the LLM decide to retry the tool call while the first request is still in flight, especially under Vapi's function-calling retry logic. That's why bookAppointment above checks an idempotency key before writing: without it, a caller who says "did that go through?" during a slow response can end up double-booked.
Voicemail and answering-machine detection
If your telephony provider forwards a voicemail greeting into the call as audio, a naive assistant will try to "schedule an appointment" with an IVR menu. Use your carrier's answering-machine detection (Twilio's AMD, or Vapi's built-in equivalent) and hang up cleanly rather than looping on a tool call that will never resolve.
Timezone handling on the PMS side
Open Dental, Dentrix, and Eaglesoft all store appointment times in the practice's local timezone, but your Worker runs at the edge in whatever region served the request. Always pass and parse dates with an explicit IANA timezone (e.g., America/Los_Angeles for a Bend, Oregon practice) rather than trusting server-local time — an off-by-one-hour bug here books patients into the wrong slot silently.
Rate limits on the practice management API
Open Dental's API and Henry Schein's API Exchange both apply per-key rate limits that are easy to hit if the assistant polls availability aggressively during a single call (for example, checking three different date ranges because a caller keeps changing their mind). Cache slot lookups for 30 to 60 seconds per call session using Workers KV rather than hitting the PMS fresh on every turn.
When NOT to build this yourself
This build is a reasonable weekend-to-two-week project for a single practice with an engineer already on staff and an Open Dental instance. It stops being the right call in a few specific situations.
If the practice runs Dentrix or Eaglesoft and doesn't already have Henry Schein API Exchange access, the partner approval process alone can take longer than writing all the code above. If you're building for more than one location, you'll need multi-tenant call routing, per-location assistant configs, and separate BAAs per PMS instance — solvable, but it turns a script into a small platform. And if nobody on the team owns on-call response for a production phone system, a dropped webhook at 7pm on a Friday means a real patient can't book a real appointment, with no one watching.
In those cases, a purpose-built vendor or a managed integration is the more defensible choice than a DIY stack maintained by whoever wrote it. If you'd rather have this running without owning the on-call pager, you can book a demo and see the managed version running against a real PMS integration.
Architecture
Caller (PSTN)
|
v
Telephony carrier (Twilio / Vapi-native SIP trunk)
|
v
Vapi (STT -> LLM -> TTS orchestration)
| \
| -> ElevenLabs (streaming TTS, ~300ms to first audio)
v
Vapi custom tool call (HTTPS webhook, JSON)
|
v
Cloudflare Worker (tool router, PHI-aware logging, retries)
| \
| -> D1 / KV (call state, idempotency keys)
v
Practice Management System API
(Open Dental REST API, or Dentrix/Eaglesoft via
Henry Schein One API Exchange)
|
v
Confirmation SMS (Twilio) back to patient
Frequently asked questions
How long does it take to build a dental AI receptionist with Vapi?
A single-location build against Open Dental's documented API is a reasonable one-to-two week project for a developer already comfortable with webhooks. Add two to four weeks if the practice runs Dentrix or Eaglesoft, since Henry Schein's API Exchange partner approval is the slowest step in the whole project.
Do I need a business associate agreement (BAA) before going live?
Yes. Call transcripts and appointment data are protected health information once they're tied to a real patient, so you need signed BAAs with Vapi, ElevenLabs, and Cloudflare before any real PMS credential touches production.
Can this integrate with Dentrix or Eaglesoft instead of Open Dental?
Yes, but through Henry Schein One's API Exchange partner program rather than a public API. Plan for an approval process and a signed data-use agreement, and prototype against Open Dental first if you want to validate the call flow sooner.
What happens if the practice management system API is slow or down?
Vapi gives your webhook roughly 10 seconds before it moves on, so a slow PMS call should time out gracefully and return a spoken apology rather than leaving the caller in silence. An idempotency key on the booking write also prevents a duplicate appointment if the tool call gets retried mid-timeout.
How much does it cost to run compared to a SaaS dental AI receptionist?
Purpose-built vendors typically charge $300 to $900 a month depending on call volume and integrations. A self-hosted Vapi and Cloudflare Workers stack can run cheaper at low call volume, but that comparison ignores the engineering time to build, monitor, and maintain it.
Will patients notice they're talking to an AI, and does that hurt bookings?
ElevenLabs' streaming voices are close enough to natural speech that most callers proceed with scheduling without commenting on it, especially when the assistant handles interruptions and confirms details the way a human receptionist would. Being upfront that it's an automated assistant when asked directly avoids the trust problems that come from pretending otherwise.