Kevin Meneses
Back to articles

How to Build an AI Receptionist for Dental Clinics with Claude

Originally published on medium.com

AI AgentAi AssistantClaude CodeDental ClinicAi Receptionist
How to Build an AI Receptionist for Dental Clinics with Claude

How to Build an AI Receptionist for Dental Clinics with Claude and ElevenLabs

Dental clinics don’t lose patients because of bad marketing.

They lose them because nobody picks up the phone.

A missed call during a filling procedure. A voicemail nobody checks until Monday. A patient who wanted to book a cleaning got tired of waiting on hold and called the clinic down the street instead.

If you’re:

  • building AI agents for healthcare,
  • integrating voice AI with existing business systems,
  • or exploring what an LLM can actually do beyond chat,

This is a concrete build worth understanding.

The Front Desk Is the Bottleneck

Most clinics run the same setup. One or two front-desk staff. Phones ringing during patient hours. Nothing after 6pm or on weekends.

The result is predictable:

  • Calls during peak hours go to voicemail
  • After-hours calls go nowhere
  • Insurance questions pile up for staff who are already booking appointments

Developers discover this too late — after a clinic owner says “I know we’re losing patients, I just don’t know how many.”

There’s already a market of tools trying to fix this. Arini, Weave, Adit, Goodcall — all SaaS platforms charging $49 to $800+ a month for a closed-box voice agent.

The Real Problem Isn’t Voice Quality

Every vendor claims their AI “sounds natural” and “answers 24/7.”

That’s not the differentiator anymore.

The real problem is that most of these platforms are closed systems. You can’t touch the booking logic. You can’t customize what happens when a patient asks something outside the script. And the feature that actually matters — whether the AI writes directly into the practice’s calendar or just leaves a note for staff — varies wildly between vendors.

Build it yourself, and you own both the logic and the data flow.

Solution: Claude as the Brain, ElevenLabs as the Voice Layer

An AI receptionist has three layers, and each one does a different job:

  1. Voice orchestration (ElevenLabs Conversational AI) — handles the phone call itself: speech-to-text, low-latency text-to-speech, telephony, and interruption handling
  2. Reasoning + decisions (Claude, via function calling) — decides what the caller needs and which action to take
  3. Practice management system (PMS) write-back — the API layer that actually books, reschedules, or flags a call for staff

ElevenLabs exists because building a low-latency voice pipeline from scratch is its own project. Its Conversational AI platform handles the telephony bridge, streaming transcription, and voice synthesis — including natural-sounding, low-latency voices that are noticeably harder to distinguish from a human receptionist than most alternatives on the market. You connect your own reasoning layer through its agent tool-calling webhook.

Claude is the part that decides what to do, using tools you define.

Building a voice AI product? Start here.
ElevenLabs’ Conversational AI stack is the fastest way to ship natural-sounding, low-latency voice agents without building your own TTS/STT pipeline.
Try ElevenLabs

Implementation

The call flow, with a latency budget

Patient speaks → Twilio/PSTN → ElevenLabs Conversational AI (streaming STT)
                                    ↓ (~150-300ms)
                              your webhook (FastAPI/Node)
                                    ↓
                              Claude API (function calling)
                                    ↓
                              tool execution against the PMS
                                    ↓
                              text back → ElevenLabs (streaming TTS)
                                    ↓
                              Patient hears the response

Total latency needs to stay under roughly 1.5 seconds or the conversation feels broken. ElevenLabs already handles STT+TTS with streaming, and its low-latency voice models are built specifically to shave milliseconds off that leg of the pipeline. That leaves 800ms–1s for your webhook, the Claude call, and the PMS call. It’s tight.

1. Define the tools Claude can call

tools = [
    {
        "name": "check_availability",
        "description": "Check open appointment slots for a given date range and treatment type",
        "input_schema": {
            "type": "object",
            "properties": {
                "date_range": {"type": "string"},
                "treatment_type": {"type": "string"}
            },
            "required": ["date_range", "treatment_type"]
        }
    },
    {
        "name": "book_appointment",
        "description": "Book a confirmed appointment slot into the PMS calendar",
        "input_schema": {
            "type": "object",
            "properties": {
                "patient_name": {"type": "string"},
                "slot_id": {"type": "string"},
                "treatment_type": {"type": "string"}
            },
            "required": ["patient_name", "slot_id", "treatment_type"]
        }
    },
    {
        "name": "escalate_to_human",
        "description": "Flag the call for staff follow-up when the request is outside scope",
        "input_schema": {
            "type": "object",
            "properties": {"reason": {"type": "string"}},
            "required": ["reason"]
        }
    }
]

2. The webhook — where the real logic lives

ElevenLabs’ Conversational AI agent sends a request to your webhook every time the patient finishes a turn. A real skeleton in FastAPI:

from fastapi import FastAPI, Request
import anthropic
app = FastAPI()
client = anthropic.Anthropic()
# In-memory call state (use Redis once you scale past one instance)
call_sessions = {}
@app.post("/webhook/elevenlabs")
async def handle_call_turn(request: Request):
    payload = await request.json()
    call_id = payload["conversation_id"]
    user_text = payload["message"]["content"]
    session = call_sessions.setdefault(call_id, {"messages": []})
    session["messages"].append({"role": "user", "content": user_text})
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        system=SYSTEM_PROMPT,
        tools=tools,
        messages=session["messages"]
    )
    final_text = ""
    for block in response.content:
        if block.type == "text":
            final_text += block.text
        elif block.type == "tool_use":
            result = execute_tool(block.name, block.input)
            session["pending_tool_result"] = {
                "tool_use_id": block.id,
                "content": result
            }
    session["messages"].append({"role": "assistant", "content": response.content})
    return {"response": final_text or "One moment, please."}

Here’s the part almost everyone misses: when Claude returns a tool_use block, you have to execute that tool and send the result back as a tool_result message in the next API call, before Claude can generate the text the patient actually hears.

That means some turns need two round-trips to Claude — one to decide which tool to call, one to generate the spoken response once it has the result.

if session.get("pending_tool_result"):
    session["messages"].append({
        "role": "user",
        "content": [{
            "type": "tool_result",
            "tool_use_id": session["pending_tool_result"]["tool_use_id"],
            "content": session["pending_tool_result"]["content"]
        }]
    })
    final_response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        system=SYSTEM_PROMPT,
        tools=tools,
        messages=session["messages"]
    )
    final_text = final_response.content[0].text

Two round-trips add latency. Keep max_tokens low (512, not 1024+) and stream Claude’s response so ElevenLabs’ TTS can start speaking before the full text is ready.

3. PMS integration — where the real work is

This is 80% of the actual engineering, not the prompt. Every PMS exposes something different:

  • Open Dental: its own REST API, requires a per-clinic API key and auth scheme
  • Dentrix Ascend: a more closed API, usually requires certified partner status
  • Cloud9: REST API, but with aggressive rate limits

execute_tool isn’t just “call a clean API.” It has to handle conflicts:

def execute_tool(name, input_data):
    if name == "check_availability":
        slots = pms_client.get_open_slots(
            date_range=input_data["date_range"],
            treatment_type=input_data["treatment_type"]
        )
        return format_slots_for_claude(slots)  # readable text, not raw JSON
    elif name == "book_appointment":
        try:
            confirmation = pms_client.create_appointment(...)
            return f"Confirmed: {confirmation.id}"
        except PMSConflictError:
            return "That slot is no longer available, offer another one"
    elif name == "escalate_to_human":
        create_staff_ticket(input_data["reason"], session_transcript)
        return "Escalated to staff"

The PMSConflictError handling matters. Slots can disappear between the moment Claude checks availability and the moment it confirms. Without that check, the patient walks away with a false confirmation.

4. State across turns

A dictionary in memory only works for a single instance. Real production deployments need multiple instances, which means call_sessions has to live in Redis (or similar), keyed by the conversation_id ElevenLabs sends you, with a short TTL — the call lasts minutes, not days.

5. What actually breaks in practice

Interruptions: if the patient talks while Claude is mid-generation, ElevenLabs’ agent fires an interruption event. Your webhook needs to cancel the in-flight generation, not queue it.

Hallucinated availability: never let Claude “guess” a time slot. check_availability must always be the source of truth, and the system prompt should explicitly forbid confirming a booking without calling it first.

Voice fallback: if Claude takes longer than ~2 seconds, configure a filler response in the ElevenLabs agent (“one moment”) or the patient hangs up.

Compliance checkpoint before going live:

  • Encrypt the transport layer end-to-end
  • Redact or avoid storing PHI in logs
  • Get a signed data processing agreement with every vendor in the chain (ElevenLabs, Claude API, PMS provider)

In the EU this maps to GDPR + national health-data regulation rather than HIPAA, but the requirements are functionally the same: encryption, auditability, and a documented processing agreement with each vendor.

A Real Call, Walked Through

Patient calls at 9pm, after hours.

They want to book a cleaning and mention their insurance changed. Claude’s first tool call checks availability for the requested week. The second call escalates the insurance update, since that needs a human to verify coverage before confirming anything financial.

The patient gets a confirmed slot before hanging up. The insurance question is sitting in tomorrow’s staff queue instead of a missed voicemail.

That’s the entire value: nothing falls through the cracks between 9pm and 9am.

Ready to build this yourself?
ElevenLabs’ Conversational AI gives you the phone-ready voice layer (STT, TTS, telephony, interruption handling) so you can focus on the Claude logic and PMS integration instead of the audio pipeline.
Get started with ElevenLabs

If you’re building infrastructure like this and want to go deeper into structuring LLM tool use for production systems, feel free to connect — I write about this kind of build regularly.

Key Takeaways

  • Owning the stack (Claude + ElevenLabs) beats renting a closed SaaS platform when you need custom logic or you’re building this for multiple clinics
  • PMS write-back depth is the real differentiator — a natural-sounding voice that only leaves a note for staff isn’t solving the problem
  • Multichannel (SMS + voice) matters more than voice quality alone, since a growing share of patients prefer texting for non-urgent requests

FAQs

Can Claude handle a real-time phone call directly?
✅ No. Claude processes text, not raw audio or telephony. You need a voice orchestration layer like ElevenLabs Conversational AI in between to handle speech-to-text, text-to-speech, and the phone connection itself. Claude only handles the reasoning step.

Is this setup HIPAA-compliant out of the box?
✅ No. None of the components (Claude API, ElevenLabs, your PMS) are HIPAA-compliant by default just because you’re using them. You need signed business associate agreements with each vendor, encrypted transport, and PHI redaction in your logging before handling real patient calls.

Does this replace front-desk staff?
✅ No, and it shouldn’t try to. It absorbs after-hours calls and overflow during peak times — the calls that currently go to voicemail. Complex cases (insurance disputes, treatment plan questions) should still escalate to a human.

What does this cost compared to a SaaS platform like Weave or Arini?
✅ You pay usage-based costs (Claude tokens + ElevenLabs voice minutes) instead of a flat $49–800/month fee. For low call volumes this can be cheaper; for high volumes, run the math against ElevenLabs’ current pricing tiers before committing.

Want to try the voice layer behind this build?
Start with ElevenLabs

Looking for more builds like this?
kevinmeneses.com/en

Looking for technical content for your company? I can help — LinkedIn · kevinmenesesgonzalez@gmail.com