Originally published on medium.com

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:
This is a concrete build worth understanding.
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:
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.
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.
An AI receptionist has three layers, and each one does a different job:
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
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.
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"]
}
}
]
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.
This is 80% of the actual engineering, not the prompt. Every PMS exposes something different:
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.
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.
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:
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.
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.
â 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 ElevenLabsLooking for more builds like this?
â kevinmeneses.com/en
Looking for technical content for your company? I can help â LinkedIn · kevinmenesesgonzalez@gmail.com