All case studies
Personal project·Solo — design, build, ship

An AI Receptionist That Books Appointments

A provider-agnostic booking agent, grounded in a real schedule

Next.js 14TypeScriptPostgreSQLDrizzleOpenAIAnthropicBedrockDocker
View on GitHub
0
double-bookings — enforced in the database, not app code
4 providers
OpenAI, Anthropic, Gemini, Bedrock — swap by config
8 tools
the agent's entire surface area
Voice or text
same booking path underneath
216 tests
suite runs with no AI or email setup

Options on the table

Free-form LLM answers
A model left to answer freely will cheerfully hallucinate a time or a price that does not exist. Unusable for real bookings.
Tool-constrained agent over a real database
The model can only call a few functions; every answer comes from the live schedule and price list. It cannot make anything up.
Single vendor (OpenAI only)
Lock-in, and voice needs a second account anyway. One outage takes the whole assistant down.
Provider-agnostic adapter
One interface over OpenAI, Anthropic, Gemini and Bedrock. Swapping provider is a settings change; a backup provider answers if the first fails.

Architecture at a glance

Patient
chat or voice
API connector
auth only
Agent
8 tools
PostgreSQL schedule
source of truth
Email code + invite
verified booking

Background

This started as a side project I built for myself, to see how far a chat-first booking flow could go while staying genuinely safe. The example is a dental clinic because that is a concrete scheduling problem, but nothing inside is dental: the same product runs a physiotherapy or eye clinic by changing the list of treatments and staff.

A patient opens a web page, types or speaks, and the assistant answers and books their appointment. It runs on the clinic's own machine, so the patient list stays with them. The only thing that leaves is the conversation itself, sent to the AI company that writes the replies, never the database.

How it works

A booking reads like a normal front-desk conversation, in a few short messages:

  • Ask: The patient says what they need. The assistant answers from real information: the treatment, the price, which professionals do it, and the times that are actually open.
  • Verify: When the patient picks a time, the assistant emails a 6-digit code and asks for it back. That one step proves the email is real, so the confirmation and calendar invite have somewhere to go.
  • Book: The assistant writes the appointment and emails a confirmation with the calendar invite attached, so it drops straight into the patient's calendar.

What it will not do (on purpose)

The safety of the assistant is mostly a list of things it cannot do, and that list is enforced, not merely intended:

  • Never invents: Every time and price comes from the real schedule and price list. There is no tool for making one up.
  • Never double-books: The database refuses a second overlapping booking at the moment of writing, so even two taps in the same second cannot collide.
  • Never books as someone else: A patient can only see and change their own appointments; the login code, not a password, is what proves who they are.
  • Stays in its lane: Asked about anything outside the clinic's treatments and appointments, it says so. It can only do what its tools allow, and there is no tool for anything else.

Provider-agnostic by design

The product is not tied to any AI company. Most vendors copy the OpenAI message format, so a single adapter already covers OpenAI, Anthropic, Gemini and OpenRouter; adding one of those is a new row in a table, not new code. Bedrock and Google's native API get their own thin adapters.

Because the assistant uses AI for three separate jobs — the brain that writes replies, the ears that turn speech to text, and the mouth that turns text back to speech — each one chooses its provider independently. A clinic can run a cheap model for the conversation and pay for quality only where the patient actually hears it. Each role also takes a backup chain: if the first provider fails or is slow, the next answers the same message and the patient sees no error.

Why PostgreSQL

A booking links a patient, a professional and a treatment, and a professional only does some treatments. That is rows and links, which is what a relational database is for. Between the relational options I chose PostgreSQL for one reason: it can refuse two overlapping bookings by itself. My own code might let both through under load, but the database checks at the moment it writes, one write at a time, so the second is rejected. Double-booking is the one mistake this product cannot make, and that guarantee lives in the database, not in hopeful application logic.

What I took away

Grounding beats cleverness. Constrain the model to a few database-backed tools and let the database enforce the one rule that must never break. The LLM becomes a friendly interface, not the source of truth, and the whole thing stays safe to put in front of real people.
Want the deeper architecture behind this? Let's talk.
Get in touch