How to build a clinic scheduling voice agent
Build a clinic receptionist that finds open slots, books one appointment, and reschedules mid-call, from an empty folder to a running worker, on OpenAI.
by Mahimai
What you will build
A voice receptionist for a clinic. The caller asks what is open, the agent reads
back real upcoming weekday slots, confirms a name and reason, books, and can
move the booking mid-call. The state is small: open slots plus a single active
booking. It lives in the agent process and mirrors to the playground as a List
of slots and a Card for the appointment. The finished demo is clinic-scheduler.
You need an OpenAI key and three LiveKit values (URL, API key, API secret). And
uv. The whole stack runs on the one OpenAI key.
1. Scaffold
Pin Python to 3.11. pyproject.toml pulls the single OpenAI plugin:
[project]
name = "clinic-scheduler"
version = "0.1.0"
description = "Book a doctor appointment by voice: find an open slot, confirm it, and reschedule if plans change."
requires-python = ">=3.11"
dependencies = [
"livekit-agents[openai]>=1.6,<2.0",
"python-dotenv>=1.0",
]
.env.example lists only what this demo uses:
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
OPENAI_API_KEY=
2. A slot inventory that never goes stale
The slots are a template of (business-day offset, time, doctor) tuples, not
literal dates. The builder walks forward from today, skips weekends, and renders
the weekday label from a real date, so the spoken weekday always matches the
actual date:
_SLOT_TEMPLATE = [
(0, "9:00 AM", "Dr. Chen"),
(0, "2:30 PM", "Dr. Patel"),
(1, "10:00 AM", "Dr. Chen"),
(1, "3:00 PM", "Dr. Patel"),
(2, "11:30 AM", "Dr. Lee"),
(3, "8:30 AM", "Dr. Chen"),
]
def _build_slots() -> list[dict]:
days: list[datetime.date] = []
day = datetime.date.today()
while len(days) < 4:
day += datetime.timedelta(days=1)
if day.weekday() < 5: # Monday-Friday
days.append(day)
return [
{
"id": f"s{i + 1}",
"date": f"{days[offset].strftime('%A %B')} {days[offset].day}",
"time": time,
"doctor": doctor,
}
for i, (offset, time, doctor) in enumerate(_SLOT_TEMPLATE)
]
Call
_build_slots()per session, not at module level. A module-level call computes the inventory once when the worker starts, and every caller for days hears the same frozen dates. The entrypoint rebuilds it offdatetime.date.today()at the start of every call.
3. The four tools and the one invariant
find_slots filters the open list and publishes it. Pass a day or doctor to
narrow, omit both to show everything:
@function_tool()
async def find_slots(
self,
context: RunContext[dict],
date_preference: str | None = None,
doctor: str | None = None,
) -> str:
filtered = context.userdata["available_slots"]
if date_preference:
keyword = date_preference.lower()
filtered = [s for s in filtered if keyword in s["date"].lower()]
if doctor:
name = doctor.lower()
filtered = [s for s in filtered if name in s["doctor"].lower()]
mounted = context.userdata["ui_mounted"]
if not filtered:
_publish_slots(self.room, mounted, [])
return "No slots match that preference. Try a different day or doctor."
_publish_slots(self.room, mounted, filtered)
return f"Available slots: {_slots_summary(filtered)}"
The hard part is not booking, it is keeping one invariant true across every path
that mutates state: open slots plus the one booking must always stay a single
coherent set, with no slot lost and none double-counted. The model enforces
exactly one active booking, so book_appointment refuses a second one instead
of overwriting, then removes the taken slot from inventory:
@function_tool()
async def book_appointment(
self,
context: RunContext[dict],
slot_id: str,
patient_name: str,
reason: str,
) -> str:
existing = context.userdata.get("booking")
if existing is not None:
return (
f"You already have an appointment on {existing['date']} at "
f"{existing['time']}. To change it, ask to reschedule."
)
available = context.userdata["available_slots"]
slot = next((s for s in available if s["id"] == slot_id), None)
if slot is None:
return f"Slot {slot_id} is not available. Ask the caller to pick another."
booking = {
"slot_id": slot_id,
"date": slot["date"],
"time": slot["time"],
"doctor": slot["doctor"],
"patient": patient_name.strip() or "Unknown",
"reason": reason.strip() or "general visit",
}
context.userdata["booking"] = booking
context.userdata["available_slots"] = [s for s in available if s["id"] != slot_id]
...
That single decision (one active booking) makes the rest fall out. A move always
routes through reschedule, the one place that runs the free-old-take-new swap.
The freed slot goes back to inventory and re-sorts by its s1..s6 id, so it
reappears in its natural position instead of drifting to the end:
remaining = [s for s in available if s["id"] != new_slot_id]
remaining.append(_freed_slot(booking))
remaining.sort(key=lambda s: int(s["id"][1:]))
context.userdata["available_slots"] = remaining
cancel_appointment returns the slot without taking a new one and clears the
card. Reschedule is net-zero on inventory; a cancel returns exactly what the
booking took.
4. Mirror it on screen
Every mutation re-publishes the open List and the booking Card, so the screen
always matches state. A _ui_action helper mounts a component once then updates
it; cancel unmounts the Card so a gone booking does not linger:
def _unmount_booking(room: rtc.Room, mounted: set[str]) -> None:
if "booking" not in mounted:
return
mounted.discard("booking")
publish_ui_event(room, "Card", "unmount", component_id="booking")
The open slots render as a who-is-free schedule: each row anchors on the doctor with an initials avatar, the day as subtitle, and the time on the right.
5. The eval
The behavioral test runs the agent in text mode against the real gpt-4o-mini
and scores the conversation with LiveKit's evals judges. It asks what is open,
books the earliest, and asserts the task-completion, tool-use, and relevancy
judges pass:
await session.run(user_input="What appointments are open this week?")
await session.run(user_input="Book me the earliest one. My name is Alex Reed.")
judges = JudgeGroup(
llm=judge_llm,
judges=[task_completion_judge(), tool_use_judge(), relevancy_judge()],
)
evaluation = await judges.evaluate(session.history)
assert evaluation.all_passed
6. Run it
cp .env.example .env
uv sync
uv run python agent.py dev
Open https://playground.mahimai.ca/demos/clinic-scheduler and connect. Ask what is open, book a slot, then try to book a second one: the agent refuses and offers to move your existing appointment, because there is only ever one.