Voice agents
Voice agents in de tandheelkunde: een EPD uit 2012 temmen
Een implantologie-keten van 29 mensen in Apeldoorn verzoop in afspraakvragen aan de balie. Zes weken later neemt een voice agent 1.740 calls per week aan, zonder de agenda dubbel te boeken.

De balie om 8:47 op een dinsdag
De receptioniste in de praktijk in Apeldoorn-Zuid heeft drie lijnen die knipperen. Lijn één is een meneer wiens moeder volgende maand een controle nodig heeft. Lijn twee is de implantaatpatiënt van vorige week die niet meer weet of hij een bloedverdunner slikt. Lijn drie loopt door naar de voicemail. Het is 8:47 's ochtends. Om 9:30 staat de wachtrij op eenenveertig calls, en die zakt niet meer tot de lunch.
Dit is geen personeelsprobleem. Dit is een telefoonvormig probleem dat vastzit aan de voorkant van een keten van 29 mensen in implantologie. Twee locaties, vier implantologen, één EPD uit 2012, en ongeveer 1.740 afspraakvragen per week. De receptionistes zijn goed. Niemand boekt een consult in het verkeerde slot. Maar de patiënt die ophangt in de wachtrij belt zelden dezelfde dag terug. Dat heeft de keten gemeten. In acht weken vorige winter kostten gemiste calls ze ergens boven de €18.000 aan misgelopen eerste consulten.
Dit is het verhaal van de voice agent die we voor ze bouwden. Wat hij doet, wat hij niet doet, en de ene architectuurkeuze die meer uitmaakte dan elke prompt die we schreven.
De beperking van het EPD uit 2012
Exquise is een Nederlands tandheelkundig praktijksysteem dat al decennia in productie draait. De keten gebruikt versie 7.x, geïnstalleerd in 2012, gepatcht maar nooit vervangen. De behandelblokken staan in een SQL Server schema dat ouder is dan JSON-kolommen. De agenda heeft een eigen tabel per locatie. Het patiëntdossier praat met een apart röntgenarchief, Pearly Plan, dat op een andere server in een ander rack draait.
Er is geen REST API. Er is een stored procedure die sp_AgendaInsert heet, die negentien parameters in een specifieke volgorde aanneemt, en er is een ODBC-bridge die de vorige IT-leverancier in 2018 heeft geschreven. De bridge werkt. De bridge heeft ook, op drie gedocumenteerde momenten, hetzelfde behandelblok dubbel weggeschreven toen twee mensen aan de balie binnen dezelfde seconde op 'boek' drukten.
Daar zet je geen AI bovenop die mag schrijven.
Als je verouderde systeem ooit een double-write heeft geproduceerd onder menselijke concurrency, dan doet een AI-agent dat uiteindelijk ook. De oplossing is geen betere prompt. De oplossing is een queue met een write lock.
De gesprekstypen van de agent
De stem zelf is bewust onopvallend. Hij neemt op in het Nederlands, noemt de praktijk, vraagt waarom je belt. We hebben bewust geen 'persoonlijkheid' gebouwd. De brand voice van de keten is rustig en klinisch, en een vrolijke bot was de verkeerde keuze geweest voor iemand die belt over een kies die bij het ontbijt afbrak.
De agent verwerkt vier soorten gesprekken:
- Een bestaande afspraak verzetten of annuleren. Alleen-lezen tegen Exquise, daarna schrijven naar de queue.
- Een routine-controle of hygiëne-slot boeken. Binnen regels: geen nieuwe patiënten, geen kinderen onder de 12, geen medische complicaties in het dossier.
- Een implantaatconsult boeken. Wordt altijd in de tandartswachtrij geparkeerd. Nooit direct weggeschreven.
- Iets anders: tandartsangst, post-operatieve pijn, financieringsvragen, een ouder die belt voor een kind. Binnen vijftien seconden naar een mens.
Die vierde categorie is ruwweg 22% van de calls. De agent probeert het niet. Hij zegt 'ik verbind u door' en routeert naar de wachtrij van de balie, met een geschreven samenvatting in het CRM-ticket. De receptionistes hadden de eerste week een hekel aan de agent. Tegen week drie vroegen ze ons om meer gesprekstypen toe te voegen, omdat de agent het volume aan routinecontroles van hun lijnen had gehaald en ze eindelijk weer hun eigen gedachten konden horen.
De antistollingsvlag en de tandartswachtrij
Dit is de architectuurkeuze die ertoe deed.
Een implantaatconsult is niet zomaar een langere afspraak. Het is een klinisch moment dat afhangt van de medicatie van de patiënt. Een patiënt die een bloedverdunner gebruikt (acenocoumarol, apixaban, rivaroxaban, het bekende rijtje) heeft een INR-controle nodig, een afstemming met de huisarts, en soms een overbrugging met LMWH. Niets daarvan ontstaat in een transcript van een telefoonbot. Het ontstaat in het hoofd van een tandarts, met een röntgenfoto uit Pearly Plan open op het tweede scherm. De Nederlandse beroepsvereniging, de KNMT, publiceert de antistollingsrichtlijn die de implantologen in de praktijk gebruiken. De agent mag daar niets van overrulen.
Daarom schrijft de agent een implantaatconsult nooit direct naar de agenda. Hij bouwt een gestructureerd object (patient_id, requested_window, intake_summary, antistollings_flag, last_pano_date) en parkeert dat in een queuetabel die wij beheren. Een tandarts opent die queue één of twee keer per dag, bekijkt de röntgenfoto, bevestigt of wijst af, en triggert daarna pas het endpoint dat de agenda wegschrijft.
De antistollingsvlag is de veiligheidsgrens. De agent stelt een klein aantal vragen. 'Gebruikt u bloedverdunners?' 'Weet u de naam?' 'Heeft u in de afgelopen zes maanden een ingreep gehad waarbij u tijdelijk moest stoppen?' Als één antwoord ja of 'weet ik niet' is, gaat de vlag aan. Gevlagde consulten kunnen de queue niet omzeilen. Er is geen prompt die dat ontgrendelt. Er is geen 'weet u het zeker'-pad. Het model mag die afweging niet maken.
Dit is het stuk van voice-agent-ontwerp dat te weinig aandacht krijgt. Het zware werk zit niet in het model. Het zware werk zit in beslissen welke calls het model mag wegschrijven, en het bouwen van de rails die de andere fysiek tegenhouden.
Het schrijfpad
Hieronder de grove vorm van het endpoint dat naar de agenda schrijft. Echte code, echt schema, vereenvoudigd voor de leesbaarheid.
// 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 });
});
}
Drie dingen doen hier werk. De 409 op already-committed voorkomt de double-write die de oude bridge in het verleden geproduceerd heeft. De advisory lock op location_id serialiseert schrijfacties per praktijk, zonder de hele keten te serialiseren. En de existence check tegen Exquise zelf, niet tegen onze queue, is de gordel én bretels voor het geval een receptioniste hetzelfde slot handmatig boekt in de baliesoftware terwijl een tandarts het queue-item aan het goedkeuren is.
Niets hiervan is glamoureus. Alles bij elkaar is de reden dat de keten akkoord ging om het systeem aan de telefoonlijnen te hangen.
Twee weken in shadow mode
Voordat de agent één live call beantwoordde, hebben we hem twee weken in shadow mode gedraaid. De call routing van Exquise bleef identiek. De receptionistes namen op zoals altijd. Maar elke call werd parallel naar de agent gestreamd, en de agent produceerde een gestructureerde intent en een voorgestelde actie. Niets werd weggeschreven. De agent was passagier.
Elke avond zetten we de voorgestelde actie van de agent naast wat de receptioniste daadwerkelijk had gedaan. Receptioniste boekt een controle op woensdag 14:00; agent stelt woensdag 14:00 voor. Match. Receptioniste routeert een financieringsvraag naar de praktijkmanager; agent vlagt 'naar mens'. Match. Receptioniste parkeert een implantaatconsult voor review; agent parkeert het met de antistollingsvlag aan. Match, maar controleer de vlag.
Verschillen kwamen in een dagelijks rapport. De eerste week waren er 37 verschillen op 1.612 calls. De meeste waren een receptioniste die een slot aanbood op een zusterlocatie waar de agent nog niet van wist, wat we op dag drie hebben opgelost. Zes waren de antistollingsmisclassificatie die later in dit stuk terugkomt. Geen enkel verschil was de agent die iets wilde wegschrijven dat een receptioniste had geparkeerd. Dat was de poort waar het ons om ging. Als de agent ooit een consult had willen boeken dat de menselijke queue had gevangen, waren we in shadow gebleven tot dat verdween. Het kwam na dag elf niet meer terug, en aan het begin van week drie hebben we de lijnen omgezet.
Acht weken later
De cijfers, gemeten tussen week twee (de agent stabiliseerde) en week tien (vorige week):
- Calls beantwoord door de agent: gemiddeld 1.740 per week, piek op maandagochtend 412.
- Calls volledig afgehandeld zonder overdracht: 64%.
- Implantaatconsulten geparkeerd in de tandartswachtrij: gemiddeld 38 per week. Daarvan droegen er 11 de antistollingsvlag.
- Reviewtijd voor de tandarts: 7 tot 9 minuten per consult, gebatcht om 08:00 en 17:00.
- Belvolume voor de receptionistes: 51% omlaag. Receptionistes zijn ingezet op inloop-patiënten en verzekeringsfollow-ups, waar de marge feitelijk zit.
- Double-writes naar Exquise: nul, tegen een eerdere baseline van ongeveer één per kwartaal.
Die 64% is niet het indrukwekkende getal. Het indrukwekkende getal is dat geen van de 38 wekelijkse implantaatconsulten door de agent is weggeschreven. Elk daarvan ging langs een tandarts. Dat is het systeem zoals ontworpen.
Twee dingen die we fout deden
De eerste versie van de agent probeerde de antistollingsvraag beleefd te stellen. Hij stopte de vraag drie minuten diep in een langer intake-script. Patiënten op bloedverdunners zijn vooral ouderen. Tegen minuut drie van een telefoongesprek waren ze moe, en meerderen zeiden 'nee' terwijl het dossier 'ja' aangaf. De vlag stond in week één op ongeveer 6% van de gevlagde-eligible calls verkeerd.
We hebben de intake herschreven en de antistollingsvraag als tweede gesteld, na naam en geboortedatum. De misclassificatie zakte naar onder de 1%. De fix was gespreksontwerp, geen modelwissel. We hebben het model niet aangeraakt.
Het tweede ding dat we fout deden: we probeerden de agent in het Pearly Plan röntgenarchief te laten lezen om te checken of de laatste panoramische röntgenfoto ouder was dan vijf jaar. De read-API van Pearly Plan is op het LAN niet geauthenticeerd, en via een VPN er buitenom ook niet. We hebben die read er helemaal uitgehaald. De tandartswachtrij toont last_pano_date nu als een aparte kolom die een worker elke vijf minuten bijwerkt. De agent raakt Pearly Plan niet aan. Er was geen goede reden om hem dat aanvalsoppervlak te geven.
Het model is niet het product. De queue, de lock, en de regels over wat het model niet mag wegschrijven: dat is het product.
Het patroon in één zin
Elke voice agent die een klinisch, financieel of juridisch systeem raakt zou naar een queue moeten schrijven, niet naar het systeem. Een mens keurt goed, en pas dan schrijft een endpoint weg. Het werk van de agent eindigt bij 'parkeren'.
Dit is dezelfde vorm die we gebruiken voor de e-mailagents die facturen triëren en de chatagents die pre-sales afhandelen. Het model is snel. De queue is veilig. Het endpoint is klein. Elke laag doet precies één ding. Het meeste dat over AI-native startups geschreven wordt gaat over greenfield. De meeste Nederlandse MKB-bedrijven hebben geen greenfield. Ze hebben een EPD uit 2012, een ODBC-bridge uit 2018, en een wachtrij vol patiënten op bloedverdunners. Het patroon in dit stuk is voor hen.
Voor de keten in Apeldoorn is het resultaat saai in de beste zin van het woord. De telefoons worden opgenomen. De receptionistes gaan op tijd naar huis. De implantologen openen een queue, scrollen, klikken, en gaan verder. Niemand belt ons in paniek over een dubbel geboekt behandelblok, omdat de architectuur het niet toestaat.
Verouderde EPD's en het telefoonprobleem
Het eerste wat je deze week kunt doen, is niet voice-leveranciers vergelijken. Het is het schrijfpad op een whiteboard tekenen. Lijst elk systeem dat een call ooit zou kunnen raken. Markeer welke je veilig kunt beschrijven en welke niet. Alles in de tweede kolom heeft een queue en een menselijke goedkeurder nodig voordat er een agent in de buurt komt.
Toen we de voice agent voor deze tandartsketen bouwden, was het ding waar we steeds tegenaan liepen niet het spraakmodel. Het waren de ODBC-bridge uit 2018 en de regel dat sommige afspraken het oog van een clinicus nodig hebben voordat ze mogen bestaan. We hebben het opgelost door de agent te behandelen als een nette intake-medewerker, niet als een boekingssysteem. Wil je iets vergelijkbaars wegen, dan staat de langere versie van hoe we dat opzetten op onze pagina over AI-agents.
Open een terminal, schets je schrijfpad, en zet een cirkel om elk vakje met het woord 'verouderd' in de buurt. Daar wacht je queue om gebouwd te worden.
Kern
Een voice agent op een verouderd EPD is een queue en een write lock met een model erbovenop. De queue is het product. Het model is de nette intake-medewerker.
FAQ
Waarom lopen implantaatconsulten via een tandartswachtrij in plaats van dat de agent ze boekt?
Implantaatconsulten vragen vaak om een check op bloedverdunners en een blik op de recente röntgenfoto. De agent heeft niet de klinische context om die afweging te maken, dus parkeert hij het verzoek voor een tandarts om goed te keuren.
Werkt een voice agent met een ouder EPD zonder REST API?
Ja, zolang het schrijfpad via een queue loopt die je zelf beheert. De agent stelt voor, een mens keurt goed, en één endpoint serialiseert schrijfacties naar de oude bridge. Het model raakt het EPD nooit direct aan.
Welk aandeel van de calls handelde de agent volledig zonder mens af?
Tussen week twee en week tien werd 64% van de calls end-to-end afgehandeld. De overige 36% ging naar een receptioniste (vooral angst- of financieringsvragen) of werd in de tandartswachtrij geparkeerd voor een implantaatconsult.
Wat voorkwam dubbele boekingen in een EPD van 14 jaar oud?
Een Postgres advisory lock op location_id, plus een existence check tegen het EPD zelf voordat sp_AgendaInsert draait. Drie lagen gordel en bretels, geen daarvan in het model.