← Blog

Voice agents

Voice agents in dentistry: taming a 14-year-old Dutch EPD

A 29-person Apeldoorn implant chain was drowning in afspraak-vragen at the front desk. Six weeks in, a voice agent handles 1,740 calls a week without touching the agenda twice.

Jacob Molkenboer· Founder · A Brand New Company· 17 Jun 2026· 9 min
Cream bakelite phone receiver off-hook on dark leather blotter, green ribbon, ivory card, red wax seal corner.

The front desk at 8:47 on a Tuesday

The receptionist at the Apeldoorn-Zuid practice has three lines blinking. Line one is a man whose moeder needs a controle next month. Line two is the implantaat patient from last week who can't remember if he is on a bloedverdunner. Line three is rolling to voicemail. It is 8:47 in the morning. By 9:30 the queue will hit forty-one calls and stay there until lunch.

This is not a staffing problem. It is a phone-shaped problem stuck to the front of a 29-person dental-implant chain. Two locations, four implantologists, one EPD from 2012, and roughly 1,740 afspraak-vragen a week. The receptionists are good. Nobody books a consult into the wrong slot. But the patient who hangs up on hold rarely calls back the same day. The chain measured that. Across eight weeks last winter, dropped calls cost them somewhere north of €18,000 in lost first-consults.

This is the story of the voice agent we built for them. What it does, what it does not do, and the one architectural decision that mattered more than every prompt we wrote.

The 2012 EPD constraint

Exquise is a Dutch dental practice management system that has been in production for decades. The chain runs version 7.x, installed in 2012, patched but never replaced. The treatment blocks live in a SQL Server schema that predates JSON columns. The agenda has its own table per location. The patient record talks to a separate röntgenarchief called Pearly Plan, which lives on a different server in a different rack.

There is no REST API. There is a stored procedure called sp_AgendaInsert that takes nineteen parameters in a specific order, and there is an ODBC bridge the previous IT vendor wrote in 2018. The bridge works. The bridge has also, on three documented occasions, written the same behandelblok twice when two front-desk staff hit "boek" within the same second.

You do not put an AI on top of that and let it write.

Warning

If your legacy system has ever produced a double-write under human concurrency, an AI agent will eventually produce one too. The fix is not better prompting. The fix is a queue with a write lock.

The agent's call shapes

The voice itself is unremarkable on purpose. It answers in Dutch, identifies the practice, asks for the reason for the call. We avoided a "personality" deliberately. The chain's brand voice is calm and clinical, and a chirpy bot would have been the wrong fit for someone calling about a kies that broke at breakfast.

The agent handles four call shapes:

  • Reschedule or cancel an existing afspraak. Read-only against Exquise, then write to the queue.
  • Book a routine controle or hygiëne slot. Within rules: not a first-time patient, not a child under 12, no medical complications on file.
  • Book an implantaat-consult. Always parked in the tandarts queue. Never directly written.
  • Anything else: tandartsangst, post-operatieve pijn, financiering questions, a parent calling about a child. Hand to a human within fifteen seconds.

That fourth bucket is roughly 22% of calls. The agent does not try. It says "ik verbind u door" and routes to the receptionist queue with a written summary in the CRM ticket. The receptionists hated the agent the first week. By week three they were asking us to add more call shapes, because the agent had cleared the routine-controle volume off their phones and they could finally hear themselves think.

The antistollings-flag and the tandarts queue

Here is the architectural decision that mattered.

An implantaat-consult is not just a longer appointment. It is a clinical event that depends on the patient's medication. A patient on a bloedverdunner (acenocoumarol, apixaban, rivaroxaban, the usual list) needs an INR check, a coordination call with the huisarts, and in some cases a bridging plan with LMWH. None of that happens in a phone-bot transcript. It happens in a tandarts's head, with a Pearly Plan röntgen open on the second monitor. The Dutch professional body, KNMT, publishes the antistolling guidance the implantologists actually use. The agent is not allowed to second-guess any of it.

So the agent never writes an implantaat-consult directly to the agenda. It builds a structured object (patient_id, requested_window, intake_summary, antistollings_flag, last_pano_date) and parks it in a queue table we control. A tandarts opens that queue once or twice a day, reviews the röntgen, confirms or rejects, and only then triggers the agenda-write endpoint.

The antistollings-flag is the safety boundary. The agent asks a small set of questions. "Gebruikt u bloedverdunners?" "Weet u de naam?" "Heeft u de afgelopen zes maanden een ingreep gehad waarbij u tijdelijk moest stoppen?" If any answer is yes or "weet ik niet," the flag goes on. Flagged consults cannot bypass the queue. There is no prompt that unlocks it. There is no "are you sure" branch. The model is not allowed to make that call.

This is the part of voice-agent design that gets undersold. The hard work is not the model. The hard work is deciding which calls the model is allowed to commit, and building the rails that physically prevent the others.

The write path

Here is the rough shape of the agenda-write endpoint. Real code, real schema, simplified for clarity.

// POST /agenda/commit
// Called only after a human (tandarts or receptionist) has approved.
export async function commitBehandelblok(req: Request) {
  const { queueItemId, approverId } = await req.json();

  const item = await db.queue.findUnique({ where: { id: queueItemId } });
  if (!item) return jsonError(404, "queue item missing");
  if (item.committed_at) return jsonError(409, "already committed");
  if (item.antistollings_flag && !item.approved_by_tandarts) {
    return jsonError(403, "antistollings flag requires tandarts approval");
  }

  // Single-writer lock against Exquise. The 2018 ODBC bridge is not
  // safe under concurrent writes, so we serialize through a Postgres
  // advisory lock keyed on the practice location.
  return db.$transaction(async (tx) => {
    await tx.$executeRaw`SELECT pg_advisory_xact_lock(${item.location_id})`;

    const exists = await exquise.agenda.find({
      patient_id: item.patient_id,
      window: item.requested_window,
    });
    if (exists) return jsonError(409, "slot already booked in EPD");

    const result = await exquise.sp_AgendaInsert(toLegacyParams(item));
    await tx.queue.update({
      where: { id: item.id },
      data: { committed_at: new Date(), exquise_ref: result.ref, approver_id: approverId },
    });
    return jsonOk({ ref: result.ref });
  });
}

Three things are doing work here. The 409 on already-committed prevents the double-write that the legacy bridge has historically produced. The advisory lock keyed on location_id serializes writes per practice without serializing the whole chain. And the existence check against Exquise itself, not against our queue, is the belt-and-braces in case a receptionist manually books the same slot from the front-desk client while a tandarts is approving the queue item.

None of this is glamorous. All of it is the reason the chain agreed to put the system on the phone lines.

Two weeks in shadow mode

Before the agent answered a single live call, we ran it in shadow mode for two weeks. The Exquise call routing stayed identical. The receptionists picked up exactly as before. But every call was also streamed to the agent in parallel, and the agent produced a structured intent and a proposed action. Nothing committed. The agent was a passenger.

Each evening we diffed the agent's proposed action against what the receptionist actually did. A receptionist booked a controle at Wednesday 14:00; the agent proposed Wednesday 14:00. Match. A receptionist routed a financiering question to the practice manager; the agent flagged "hand to human." Match. A receptionist parked an implantaat-consult for review; the agent parked it with the antistollings-flag on. Match, but check the flag.

We surfaced disagreements in a daily report. The first week had 37 disagreements out of 1,612 calls. Most were the receptionist offering a slot at a sister location the agent did not yet know about, which we fixed on day three. Six were the antistollings misclassification described later in this post. None of the disagreements were the agent proposing to commit something a receptionist had instead parked. That was the gate we cared about. If the agent had ever wanted to book a consult the human queue would have caught, we would have stayed in shadow until that vanished. It did not recur after day eleven, and we cut the lines over at the start of week three.

Eight weeks in

The numbers, measured between week two (the agent stabilised) and week ten (last week):

  • Calls answered by the agent: 1,740 per week average, peak Monday morning 412.
  • Calls fully resolved without human handoff: 64%.
  • Implantaat-consults parked in the tandarts queue: 38 per week average. Of those, 11 carried the antistollings-flag.
  • Tandarts queue review time: 7 to 9 minutes per consult, batched at 08:00 and 17:00.
  • Receptionist call volume: down 51%. Receptionists redeployed to walk-in patients and verzekering follow-ups, where margin actually lives.
  • Double-writes to Exquise: zero, against the bridge's prior baseline of roughly one per quarter.

The 64% resolution rate is not the impressive number. The impressive number is that none of the 38 weekly implant consults were committed by the agent. Every one of them passed through a tandarts. That is the system working as designed.

Two things we got wrong

The first version of the agent tried to be polite about the antistollings question. It buried the question inside a longer intake script, three minutes in. Patients on bloedverdunners are mostly elderly. By minute three of a phone call they were tired, and several said "nee" when the chart said "ja." The flag was wrong on roughly 6% of flagged-eligible calls in week one.

We rewrote the intake to ask the antistollings question second, after name and birthdate. Misclassification dropped to under 1%. The fix was conversational design, not a model swap. We did not change the model.

The second thing we got wrong: we tried to let the agent read the Pearly Plan röntgenarchief to check whether the last panoramic X-ray was more than five years old. Pearly Plan's read API is unauthenticated on the LAN and on a VPN off it. We pulled the read entirely. The tandarts queue now displays last_pano_date as a separate field that a worker job populates on a five-minute schedule. The agent never touches Pearly Plan. There was no good reason to give it that surface area.

Takeaway

The model is not the product. The queue, the lock, and the rules about what the model is not allowed to commit are the product.

The pattern in one sentence

Any voice agent that touches a clinical, financial, or legal system should write to a queue, not to the system. A human approves, and only then does an endpoint commit. The agent's job ends at "park."

This is the same shape we use for the email agents that triage invoices and the chat agents that handle pre-sales. The model is fast. The queue is safe. The endpoint is small. Each layer does exactly one thing. Most of the AI-native-startup writing assumes a greenfield. Most Dutch SMEs do not have one. They have a 2012 EPD, a 2018 ODBC bridge, and a queue of patients on bloedverdunners. The pattern in this post is for them.

For the Apeldoorn chain, the result is mundane in the best way. The phones get answered. The receptionists go home on time. The implantologists open a queue, scroll, click, and move on. Nobody calls us in a panic about a double-booked behandelblok, because the architecture does not permit one.

Legacy EPDs and the phone problem

The first thing to do this week is not to evaluate voice vendors. It is to draw the write path on a whiteboard. List every system the call could ever touch. Mark the ones that are safe to write to and the ones that are not. Anything in the second column needs a queue and a human approver before any agent goes near it.

When we built the voice agent for this dental chain, the thing we kept running into was not the speech model. It was the 2018 ODBC bridge and the rule that some appointments need a clinician's eyes before they exist. We ended up solving it by treating the agent as a polite intake worker, not a booking system. If you are weighing something similar, our AI agents page has the longer version of how we structure these.

Open a terminal, sketch your write path, and circle every box with the word "legacy" near it. That is your queue, waiting to be built.

Key takeaway

A voice agent on a legacy EPD is a queue and a write lock with a model bolted on. The queue is the product. The model is the polite intake worker.

FAQ

Why route implant consults through a dentist queue instead of letting the agent book them?

Implant consults often need a check on bloedverdunners and a review of recent röntgen. The agent does not have the clinical context to make that call, so it parks the request for a tandarts to approve.

Will a voice agent work with an older EPD that has no REST API?

Yes, as long as the write path goes through a queue you control. The agent proposes, a human approves, and a single endpoint serialises writes to the legacy bridge. The model never touches the EPD directly.

What share of calls did the agent fully handle without a person?

Across weeks two to ten, 64% of calls were resolved end-to-end. The remaining 36% were handed to a receptionist (mostly angst or financiering questions) or parked in the tandarts queue for an implantaat-consult.

What stopped double-bookings in a 14-year-old EPD?

A Postgres advisory lock keyed on location_id, plus an existence check against the EPD itself before sp_AgendaInsert runs. Three layers of belt and braces, none of which involve the model.

voice agentsai agentsautomationintegrationscase studyarchitecture

Building something?

Start a project