← Blog

Voice agents

Voice agents bij tandartsketens: de vrijdagpiek-playbook

Vrijdag 16:45. Driehonderdtachtig ouders bellen veertien tandartspraktijken tegelijk. De assistentes zijn naar huis. De voice agent beantwoordt elke lijn.

Jacob Molkenboer· Oprichter · A Brand New Company· 7 jun 2026· 9 min
Crèmekleurige Bakelieten hoorn op inktvlek-leer, groen lint in open agenda, rode lakzegel op briefje bij raamlicht.

Vrijdag 16:45. Driehonderdtachtig ouders draaien hetzelfde nummer. Veertien Nederlandse tandartspraktijken, één gedeelde afsprakenlijn, één hectisch venster van 25 minuten voordat de assistentes naar huis gaan.

De oude setup nam de eerste 40 calls aan en dumpte de rest in een voicemail die het hele weekend vol bleef staan. Op maandag stonden de praktijkmanagers callbacks te triëren voor afspraken die ouders al ergens anders hadden gemaakt.

Dit is precies het soort probleem waar een voice agent goed in is. Geen "AI-receptionist die alles doet", maar een smalle agent die twee voorspelbare taken aankan: een afspraak inplannen en vragen beantwoorden over prestatiecodes (de NZa-codes die bepalen wat de verzekering vergoedt). Dit is de playbook die wij gebruikten.

Capaciteit is een telefonieprobleem, geen modelprobleem

De eerste reflex is om aan LLM-throughput te denken. Verkeerde laag. Bij 380 gelijktijdige calls zit je bottleneck in je SIP trunk en je media workers.

Een standaard Twilio elastic SIP trunk geeft je honderden gelijktijdige verbindingen per trunk, maar de carrier-side rate limit voor nieuwe call setups (CPS) is wat je in een piek de das omdoet. Als 380 ouders binnen hetzelfde venster van 30 seconden bellen, zit je inbound CPS rond de 13 calls per seconde. De meeste Europese CPaaS-aanbieders staan standaard op 5 tot 10 CPS, tenzij je erom vraagt. Open een ticket bij de carrier en laat de limiet verhogen vóór de lancering, niet tijdens.

Voor media routing gebruiken we LiveKit Agents op een klein Kubernetes-cluster. Elke worker pakt comfortabel 8 tot 12 calls tegelijk. Voor een plafond van 400 calls houden we een warm pool van 40 workers aan, plus een hot-standby pool die schaalt op call_queue_depth, niet op CPU. CPU liegt bij voice-workloads, omdat het meeste wachten op de LLM en TTS zit, niet op lokale rekenkracht.

Het latency-budget van 800ms

Een telefoongesprek voelt kapot bij zo'n 800ms stilte. De voice-pipeline heeft meer hops dan mensen verwachten:

  1. Spraak van de beller, daarna detecteert VAD het einde van de beurt (rond 180ms).
  2. ASR partial wordt gefinaliseerd (rond 120ms).
  3. Eerste token van de LLM (rond 240ms).
  4. Eerste audio van TTS (rond 200ms).
  5. Audio bereikt de beller (rond 60ms netwerk).

Bij elkaar opgeteld ~800ms op een goede run. Er zit geen marge in. Elk component is gekozen op tail latency, niet op gemiddelde:

  • ASR: Deepgram Nova met streaming partials. Whisper is sneller op offline benchmarks en trager op p95 streaming.
  • LLM: een klein open-weights model voor routing, dat alleen opschaalt naar een frontier model als de router twijfelt.
  • TTS: Cartesia Sonic, omdat time-to-first-audio de enige TTS-metric is die telt aan de telefoon.

Voor Nederlands is ASR specifiek de zwakste schakel. Accenten uit Limburg, Brabant en de Randstad verschillen genoeg om een word error rate van 4% op een benchmark in de praktijk naar 11% te tillen. Test op echte opnames uit de praktijken zelf, niet op Common Voice.

De kennisbank met prestatiecodes

De Nederlandse tandartsverzekering vergoedt per prestatiecode. C11 is de periodieke controle. C22 is de uitgebreide intake. M01 is mondhygiëne. V-codes dekken vullingen en hangen af van het aantal vlakken en het materiaal.

Ouders kennen die niet. Ze vragen "krijgt Mees een vulling op zijn achterkies vergoed?". De agent moet dat vertalen naar V21/V22/V31/V32-gebied, de basisverzekeringsregels checken (geen vergoeding voor vullingen bij volwassenen, volledige vergoeding onder de 18), en in gewoon Nederlands antwoorden.

We hebben dit gebouwd als retrieval-laag, niet als fine-tune. De NZa publiceert elk jaar in januari de mondzorg-tarievenlijst als CSV. We ingesten die, embedden op code plus omschrijving plus typische ouder-bewoordingen ("voorkant", "kies achterin", "spoedvulling"), en slaan op in pgvector. Een retrieval-call zit binnen de LLM-hop en kost zo'n 80ms extra.

Let op

De NZa-codes updaten elk jaar in januari en soms ook tussentijds. Je kennisbank klopt vanaf dag één niet meer als je geen refresh-job hebt. We pollen het NZa-portaal elke maandag om 03:00 en diffen tegen de vorige snapshot voordat de agent ooit een nieuwe versie te zien krijgt.

Confidence routing en de stille handoff

De dure faalmodus is niet "de agent weet het niet". Het is "de agent zegt vol vertrouwen iets verkeerds over de vergoeding". De ouder boekt, komt langs, krijgt een rekening van 180 euro die hij niet had verwacht, en de praktijk draait op voor het goodwill-verlies.

We routen op confidence in twee lagen:

  1. Retrieval confidence. Als de top-k chunks onder de drempel scoren, of uit verschillende codes komen, doet de agent geen uitspraak over de vergoeding. Hij boekt de afspraak en markeert die voor de praktijkmanager om de volgende ochtend per sms te bevestigen.
  2. LLM self-confidence. Het model krijgt de instructie om een JSON tool call uit te sturen met certainty: high|medium|low. Alles onder high bij een vergoedingsvraag triggert dezelfde flag.

Beide paden gebruiken dezelfde overdrachtszin: "Ik boek de afspraak vast in en de praktijk belt u maandagochtend terug over de vergoeding". Geen stilte, geen wachtmuziek, geen excuus-theater. De ouder krijgt waarvoor hij belt (de slot) en de mens lost het stuk op dat echt om een mens vraagt.

Testen op 380 gelijktijdige calls

Een voice agent kun je niet op staging testen zoals je een webapp test. Het telefoonnetwerk is echt, de LLM-provider is echt, de TTS streamt echte audio. Een echte load test moet dus echt calls plaatsen.

We draaien op twee lagen:

  • Synthetische call generator. Een Python-script met pjsua2 plaatst calls naar een staging-nummer en speelt vooraf opgenomen ouder-uitspraken af. Goedkoop, snel, maar belast de echte carrier niet.
  • Real-trunk burst. Eén keer per week draaien we een venster van 5 minuten tegen de testomgeving van de live carrier, oplopend van 50 naar 400 calls. Dit is de test die CPS-throttles, SIP REGISTER-limieten en DNS rate-limits vangt waarvan je niet wist dat ze bestonden.

Het skelet van de synthetische generator ziet er zo uit. De initialisatie van Endpoint en Account (libInit, UDP-transport, libStart, Account.create met registratie) is standaard pjsua2-boilerplate en is voor de lengte weggelaten; ga ervan uit dat ep en acc live zijn bij module-import.

import asyncio
import random
import pjsua2 as pj

# ep: pj.Endpoint and acc: pj.Account are assumed initialized.
# See https://docs.pjsip.org/en/latest/pjsua2/intro.html for setup.

class PlayerCall(pj.Call):
    def __init__(self, account, wav_path):
        super().__init__(account)
        self._wav = wav_path
        self._player = None

    def onCallMediaState(self, prm):
        for mi in self.getInfo().media:
            if (mi.type == pj.PJMEDIA_TYPE_AUDIO
                    and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE):
                self._player = pj.AudioMediaPlayer()
                self._player.createPlayer(self._wav, pj.PJMEDIA_FILE_NO_LOOP)
                self._player.startTransmit(self.getAudioMedia(mi.index))

async def place_call(target: str, wav_path: str):
    call = PlayerCall(acc, wav_path)
    call.makeCall(target, pj.CallOpParam(True))
    await asyncio.sleep(random.uniform(45, 90))
    call.hangup(pj.CallOpParam(True))

async def burst(target: str, n: int, ramp_s: int):
    async def fire(i):
        await asyncio.sleep(random.uniform(0, ramp_s))
        await place_call(target, f"utterances/{i % 40}.wav")
    await asyncio.gather(*(fire(i) for i in range(n)))

if __name__ == "__main__":
    asyncio.run(burst("sip:staging@agent.example.nl", 400, 30))

Veertig vooraf opgenomen uitspraken dekken de long tail van echte ouder-intenties: controle, vulling, eerste bezoek van een kind, losgeraakt beugelblokje, spoed bij de achterkies, betaalvraag, afspraak verzetten, overschakelen naar Engels. Ze één keer opnemen met de echte assistentes, niet met synthetische TTS, maakt de load test eerlijk.

Observability is het eigenlijke product

Een voice agent zonder opnames is een black box. Elke call legt vast:

  • Volledige audio, beller en agent op aparte kanalen, encrypted at rest.
  • ASR-transcript met word-level timestamps.
  • De opgehaalde KB-chunks voor elke LLM-turn.
  • Een confidence trace: zekerheid van de router, retrieval-score, zelfbeoordeling van de LLM.
  • De uiteindelijke uitkomst: afspraak gemaakt, callback gemarkeerd, doorverbonden naar een mens.

De praktijkmanager opent elke maandagochtend een dashboard en ziet zoiets als: "12 calls gemarkeerd voor callback dit weekend, 4 over V-codes, 6 over nazorg implantaten, 2 over een spoedgeval buiten kantoortijden". Die lijst werkt hij in 25 minuten weg. De agent handelde de andere 1.420 calls af zonder dat iemand het merkte.

Waar niemand je voor waarschuwt: AVG. Gespreksopnames worden bijzondere persoonsgegevens zodra ze naast gezondheidscontext staan. We bewaren opnames 30 dagen, transcripts 90 dagen, en de gestructureerde boekingsdata voor de termijn die de verwerkersovereenkomst van de praktijk voorschrijft. Het dashboard toont het bewaarbeleid op elk scherm, zodat niemand een transcript in een groepschat plakt zoals klantenservice-teams dat soms doen.

De dagelijkse review-loop

Elke ochtend om 06:00 sampelt een job 30 calls uit de vorige dag, draait elke call door een "ging dit goed?"-classifier (prompt plus een klein judge-model), en post de onderste 5 in een Slack-kanaal dat de praktijkmanager en ons team delen. Negen van de tien keer is het antwoord "ja, de agent heeft het opgelost". Die tiende call is goud waard. Hij wijst op een ontbrekende KB-entry, een formulering die de ASR verkeerd hoort, of een confidence-drempel die bijgesteld moet worden.

Na acht weken bracht de bottom-5-sample geen echte issues meer naar boven. We zetten de dagelijkse review terug naar wekelijks. De agent handelt nu ongeveer 90% van de inkomende calls end-to-end af, boekt zo'n 71% daarvan rechtstreeks in de praktijkagenda zonder dat een mens de boeking aanraakt, en markeert de rest voor het ochtendteam.

Wat het de praktijk kost om dit te negeren

Toen we de voicelijn voor deze Nederlandse tandartsketen bouwden, was het stuk dat het langst duurde niet de LLM. Het waren de capaciteitsonderhandelingen rond de SIP trunk en de kennisbank met prestatiecodes. We losten het vrijdagpiek-probleem uiteindelijk op door de worker pool 20 minuten voor de wekelijkse piek vast op te warmen en de carrier voor dat venster te pinnen op een hoger CPS-plafond. Hetzelfde patroon werkt voor elke voice agent op een echte telefoonlijn, of die nu opneemt voor een praktijk, een APK-garage of een regionaal logistiek dispatchcentrum.

Zit je op een lijn die calls laat vallen bij de piek en heb je niet gemeten wat die gemiste calls je kosten, dan is dit de audit van vijf minuten: trek de CDR (call detail records) van vorige maand uit je PBX, tel de calls die op vrijdag tussen 16:00 en 17:00 in de voicemail belandden, en vermenigvuldig met je gemiddelde afspraakwaarde. Dat getal is meestal groter dan de prijs om het te fixen.

Kern

Een voice agent op 380 gelijktijdige calls is een telefonie-, data- en AVG-project voordat het een AI-project is. Het model is het makkelijke stuk.

FAQ

Kan een voice agent echt 380 gelijktijdige calls aan?

Ja, maar de bottleneck zit in je SIP trunk en de rate limits van de carrier, niet in het taalmodel. Plan capaciteit eerst op de telefonielaag, en warm worker pools alvast op voor bekende pieken.

Wat gebeurt er als de agent het antwoord niet weet?

Hij boekt de afspraak, markeert de call voor menselijke opvolging in de ochtend, en vertelt de beller precies wat hij vervolgens kan verwachten. Hij gokt nooit naar de vergoeding.

Mag je telefoongesprekken opnemen onder de AVG?

Ja, met een rechtsgrond, een duidelijke mededeling aan de beller en een korte bewaartermijn. Gesprekken in een zorgcontext zijn bijzondere persoonsgegevens, dus bewaar audio maximaal 30 dagen en documenteer het beleid waar het personeel het dagelijks ziet.

Hoe accuraat is Nederlandse spraakherkenning voor tandheelkundige termen?

Out of the box rond de 11% word error rate in de praktijk. Met per-praktijk vocabulaire-tuning en zo'n 30 uur in-domain audio kregen we het onder de 5% op woorden die voor de vergoeding tellen.

voice agentsai agentsautomationragcase studyoperations

Iets bouwen?

Start een project