An AI receptionist you can call right now
EveryDesk answers the phone when a practice can't. It triages the caller, captures the request, and puts it on a staff dashboard while the caller is still talking. Three numbers are live: a dental office, a med spa, and a vet clinic. v1 captures and queues rather than books, which is a decision and not a gap. The interesting part of this build was never the AI. It was everything the phone line does wrong.
- 476
- automated tests across four suites, all passing
- 3
- live numbers: dental, med spa, vet
- 13
- call scenarios replayed offline through the real agent loop
- 2
- real-call failures found and pinned as regression tests
EveryDesk
Three lines, answering today
Dental
(512) 812-9669
Triage and message-taking
Med spa
(737) 214-0621
Triage, plus times and holds
Vet
(737) 258-4963
Triage, plus times and holds
One agent, three verticals. The vertical is set by which number you dial.
The problem
A call to a dental office at 9pm reaches voicemail, and some of those callers are in real pain. That part is easy to describe. The hard part is that a phone call is a hostile interface. The telephony vendor's documented wire format did not match what it actually sent us. Its noise cancellation, by design, does not remove the one kind of noise that broke our calls. And the model on the other end must never diagnose a patient, never promise a time it can't keep, and never let a bad transcript turn an emergency into a routine callback. You find out about constraints like these from a real call, not from a spec.
What we built
- The call loop. A caller dials a real number. Retell handles telephony, speech recognition, and voice, then opens a websocket to our FastAPI service. A Claude agent loop runs with server-side tools and returns spoken text plus an end-call flag. The agent posts a structured request to a Django backend, and it lands on the staff dashboard during the call.
- Triage guarded in code. A validator rejects incoherent priority and escalation combinations before anything is saved, so an emergency can't be filed with no action and a routine call can't carry one. If the model emits a bad combination, the save fails loudly instead of quietly queuing a misrouted request. The vertical is stamped server-side from practice config. The model never picks it.
- Hardening against background speech. Two real calls failed on a TV in the room and a conversation nearby. Each fix has a number and a written cost: background speech cancellation at half a cent per minute, which may suppress a far-field speakerphone caller; interruption sensitivity from 0.7 to 0.45; responsiveness from 0.9 to 0.8; accurate speech recognition, trading latency. After a few unclear exchanges the agent stops trying and captures a name and a number, tagged so staff know the line was bad. Triage is carved out. A suspected emergency heard through noise is confirmed once, and a yes or an unclear answer is treated as an emergency.
- Scheduling that degrades. Every scheduling tool falls back to taking a message. A backend error and an empty calendar return different values on purpose, so the agent can tell "let me try again" apart from "nothing's open." When a hold conflicts, the agent re-checks and re-offers. The backend takes the slot under a row lock in a transaction.
- Two-phase persistence. The transcript and duration ride the mid-call save, so the request is complete when it appears. The recording URL can't: the vendor only processes it after hangup. A background task polls and patches it in later. If that backfill fails entirely, the request degrades to the mid-call snapshot. It is never lost.
- A written scope. The dental line books nothing and no line touches a practice management system, by design. Med spa and vet can offer open times and hold one against EveryDesk's own scheduling backend. SMS is mocked: every outbound message is recorded, none is sent. The cost figures in the repo are projections, and the live path is the pricier one. No BAAs are signed, so the system has never handled real patient data. The repo keeps this list, and the runbook says so out loud.
How it's built
Python 3.12 throughout. A FastAPI voice service holds the websocket and the agent loop; Django 5 and DRF hold the data, the scheduling, and the API; React with TypeScript and Vite is the staff dashboard; PostgreSQL 16 underneath. The model is Claude Sonnet 4.6, with a hard cap on tool rounds per turn. Seven versioned prompt files, revised by writing a new version rather than editing the old one. Thirteen scripted scenarios replay through the real agent loop and the real validation with no API key and no backend running, which is how the definition of done stays checkable. Production is a Hetzner box with Caddy and systemd, deployed by rsync and a service restart. Per-call state is an in-process dict, which is why the docs mandate a single worker and flag that as the seam to cut first.
The docs didn't match the wire
The first real inbound call proved our integration wrong. Actual frames carry no caller number and no start timestamp, and the turn frame has no call object at all. Our tests passed anyway, because our simulator sent the frames the docs described. The fix: the call envelope now starts from a sentinel number and gets enriched once per call from the vendor's API, best effort, with a five second timeout. Any failure leaves the sentinel in place, so a live call never breaks on enrichment. Then we rewrote the test suite to drive real-shaped frames, so a simulator can never mask this class of bug again. That rewrite is most of why the test count is what it is.
Outcomes
- 476 automated tests pass across four suites: 239 backend, 135 voice, 37 notifications, 65 frontend.
- Three numbers are live and callable, one each for dental, med spa, and vet, served by one agent that reads its vertical from practice config.
- Two failure modes found on real calls, a TV in the room and a nearby conversation, are now scripted scenarios that run offline against the real agent loop.
- Safety invariants are enforced server-side, so an incoherent triage fails the save rather than reaching the queue.
- Every scheduling path has a message-taking fallback, and a total backfill failure still leaves a complete mid-call request.