Voice agents
Voice agent op .NET 4.5: playbook Rotterdamse zorgbroker
Hoe we een Nederlandse en Papiamentse voice agent uitrolden tegen een 14 jaar oude .NET 4.5 polisadministratie, met code-rood escalaties onder de 60 seconden.

09:14 op een dinsdag. Maria belt vanuit Charlois. Haar dochter is net 18 geworden, de aanvullende tandartsdekking moet over naar haar eigen polis, en er zijn nog veertien minuten voordat haar tram Rotterdam Centraal binnenrijdt. De medewerker aan de lijn is drie weken geleden aangenomen en heeft nog niet geleerd hoe je een polis vindt als het BSN met een Papiaments accent wordt opgegeven.
Dit is wat de broker elke dinsdagochtend had. Geen moeilijk werk. Repetitief werk. Het soort werk dat leeft in het gat tussen een polishouder die veertig seconden nodig heeft en een CRM dat vier minuten nodig heeft.
De brief van de directeur was kort: bouw een voice agent die de makkelijke calls uit de wachtrij haalt, Nederlands en Papiaments aankan, en nooit een code-rood mist.
De intake waar we instapten
Drieëndertig mensen, inclusief de directeur. Twee telefoonlijnen, één algemene en één zakelijke, samen 2.180 inkomende calls per week. Ongeveer 64% van die calls raakte één polis. Rond de 18% waren vragen over aanvullende pakketten die niemand op de eerste lijn mocht toezeggen. Een kleine fractie, die we op 1,1% maten, waren overlijdensmeldingen die binnen minuten een senior medewerker eisten, geen uren.
De polisdata leefde in een custom .NET 4.5 polisadministratie, in 2012 in opdracht gemaakt door een developer die inmiddels naar Australië is geëmigreerd. SQL Server 2012 op een Hyper-V host in een Rotterdams datacenter. WCF endpoints over basicHttpBinding, het soort WCF waar elke methode 200 OK retourneert en de echte error code begraven ligt in de SOAP envelope.
.NET 4.5 kreeg in 2016 zijn end of support, wat betekent dat er niemand te bellen valt als de WCF host een existentiële crisis krijgt. De broker had een half-Roemeense, half-Nederlandse sysadmin die wist waar de lijken lagen. We hadden geen toestemming om te refactoren. We hadden toestemming om te integreren.
De randvoorwaarden die het ontwerp bepaalden
Drie randvoorwaarden deden het meeste werk.
Eén: de polisadministratie mocht niet worden aangepast. Elke database write ging via de WCF facade of via de medewerker-UI. Geen nieuwe endpoints, geen 'we voegen even één stored procedure toe'. Het acceptant-team had vier jaar besteed aan het schoonmaken van de data binnen dat schema en ze gingen niet toelaten dat een voice agent het opnieuw vies maakte.
Twee: de call moest aanvoelen als een Rotterdamse call. Geen Brusselse call, geen Hilversumse call. Het dialect doet ertoe. De meeste polishouders in het boek van de broker wonen in Rotterdam-Zuid. Een niet-triviaal deel spreekt thuis Papiaments en geeft daar de voorkeur aan zodra het gesprek over geld of gezondheid gaat. ASR die hun BSN verkeerd verstaat is erger dan geen ASR.
Drie: AVG. De compliance officer wilde audio retentie omlaag naar zeven dagen en volledige verwijdering op verzoek, met gestructureerde PII redactie in de transcripts. De pipeline moest ervan uitgaan dat elke call een BSN, een IBAN en een medische verklaring bevatte.
De voice stack
We eindigden met vijf bewegende onderdelen.
Een SIP trunk van de bestaande provider van de broker, eindigend in LiveKit Agents op een Hetzner box in Falkenstein. LiveKit verzorgde het realtime audio plumbing en de half-duplex turn-taking. We wilden geen per-minuut media-kosten betalen aan een Amerikaanse leverancier voor wat in essentie een Rotterdam-naar-Rotterdam call is.
Voor ASR: Deepgram Nova-2 multilingual op het Nederlandse pad, en een fallback Whisper-large-v3 deployment op één A100 voor Papiaments. Deepgram noemt Papiaments officieel niet; in de praktijk transcribeerde het rond de 71% van de Papiamentse uitingen als verminkt Nederlands, wat erger is dan nutteloos. Whisper kan Papiaments goed genoeg aan voor intent classificatie, maar niet goed genoeg voor BSN-uitvraag, waar we straks op terugkomen.
Voor het brein: een LLM agent met een strak tool-oppervlak. Zes tools, allemaal wrappers rond de WCF facade of het queue systeem. Voor TTS: ElevenLabs met een Nederlandse vrouwenstem, gekloond uit een sample van 12 minuten van één van de bestaande senior medewerkers van de broker. De polishouder moet de stem herkennen. Die herkenning doet meer voor vertrouwen dan welk onboarding-script we ook konden schrijven.
Voor de queue: een kleine Node service op dezelfde Hetzner box met drie RabbitMQ queues: triage, acceptant en code-rood.
// tools/lookupPolis.ts
// Thin wrapper around the WCF facade. Returns a normalised polis object.
export const lookupPolis = {
name: "lookup_polis",
description: "Find a polis by BSN or polisnummer. Read-only.",
input_schema: {
type: "object",
properties: {
identifier: { type: "string", description: "BSN (9 digits) or polisnummer" },
kind: { type: "string", enum: ["bsn", "polisnummer"] }
},
required: ["identifier", "kind"]
},
handler: async ({ identifier, kind }) => {
const soap = buildSoapEnvelope("GetPolisByIdentifier", { identifier, kind });
const res = await fetch(WCF_URL, {
method: "POST",
headers: { "Content-Type": "text/xml", SOAPAction: "GetPolisByIdentifier" },
body: soap
});
const body = await res.text();
if (body.includes("<faultcode>")) {
// WCF returns 200 OK even on faults. Parse the envelope, do not trust the status.
return { ok: false, reason: parseFault(body) };
}
return normalise(parseEnvelope(body));
}
};
WCF over basicHttpBinding retourneert bij de meeste server-side fouten gewoon HTTP 200 OK. Parse altijd de SOAP envelope voordat je beslist dat de call is geslaagd. Laat je de agent een 200 als succes behandelen, dan commit hij vrolijk rommel in een 14 jaar oud schema.
Nederlands en Papiaments zonder Babel-vis
Taaldetectie op de eerste 1,2 seconden audio is onbetrouwbaar, zeker bij code-switching. Polishouders wisselen binnen één zin tussen Nederlands en Papiaments, en het model dat die switch netjes afhandelt bestaat voor ons budget nog niet.
We deden drie dingen.
We lieten de polishouder 1 voor Nederlands, 2 pa Papiamentu drukken voordat de agent opneemt. Ongeveer 38% drukt 2. Daarvan switcht rond de 19% binnen de eerste dertig seconden terug naar Nederlands. Prima. De agent draait elke 800ms een second-pass taaldetector en wisselt de ASR pipeline zodra de confidence drie opeenvolgende beurten boven de 0,84 blijft.
We hebben de BSN-uitvraag geïsoleerd. De agent probeert nooit een BSN uit vrije spraak op te pikken. Hij vraagt de polishouder om het BSN op het keypad in te toetsen, leest het cijfer voor cijfer terug in de gekozen taal, en wacht op ja of sí voor hij doorgaat. DTMF wint altijd van ASR op een string van negen cijfers, in welke taal dan ook.
We logden elke Papiamentse beurt voor menselijke review in week één. Het acceptant-team markeerde 41 mistranscripties in de eerste 600 Papiamentse calls. Die 41 gebruikten we als finetune-set voor een kleine intent classifier die tussen Whisper en de LLM zit. Intent accuracy op Papiaments ging in twee weken van 84% naar 96%.
De routing-regels
De agent maakt geen polisbeslissingen. Hij verplaatst calls naar de juiste queue, en doet het read-only werk waar geen mens voor nodig is.
De triage queue handelt polis-wijzigingen af die de agent van begin tot eind kan voltooien: adreswijzigingen, IBAN-wijzigingen, een bekende polishouder toevoegen aan een bestaand aanvullend pakket binnen het standaard window. De agent commit deze via de WCF facade en leest de bevestiging terug. Ongeveer 47% van de inkomende calls komt hier binnen.
De acceptant queue handelt elke aanvullende-pakket-vraag af die underwriting nodig heeft. De agent verzamelt de intentie van de polishouder, het gewenste pakket en de antwoorden op de gezondheidsverklaring wanneer de polishouder die telefonisch wil geven, en zet een kaart op een kanban-bord waar de acceptanten vanaf werken. De gemiddelde tijd tussen call-einde en eerste touch van de acceptant daalde van 19 uur naar 41 minuten.
De code-rood queue handelt overlijdensmeldingen af. De classificatie is bewust ruim. Elke vermelding van overleden, gestorven, weggegaan of het Papiamentse morto pa muri triggert het, plus elke zin waarin de beller zichzelf identificeert als nabestaande of echtgenoot van een bestaande polishouder. False positives gaan naar een senior medewerker die ze binnen een minuut sluit. False negatives zouden catastrofaal zijn.
Zestig seconden voor code-rood
De SLA waar de directeur om vroeg was simpel. Vanaf het moment dat de agent een call als code-rood classificeert, hangt een senior medewerker binnen 60 seconden aan de lijn.
Dat halen we niet met een webhook. Webhooks waaieren uit over een CRM, een notification service en een router, en 60 seconden is niet genoeg budget voor een ketting van HTTP-calls als één ervan kan blijven hangen.
Wat wel werkt: een dedicated WebSocket channel dat de seniors openhouden op hun workstation, met een kleine Mac menu bar app die bij een code-rood alles laat vallen en full-screen schiet. De agent blijft aan de lijn, speelt een korte vooropgenomen boodschap af in de gekozen taal ('een collega komt zo voor u op de lijn'), en doet een warm transfer zodra de senior opneemt. Mediane handoff tijd over de eerste 90 dagen: 23 seconden. Worst case in 90 dagen: 51 seconden.
# escalation.py
# When the classifier returns code_rood, we do not POST. We push.
async def on_intent(intent: Intent, call: Call):
if intent.label == "code_rood":
await audio.play(call, prompts.warm_hold[call.lang])
seniors = ws_registry.online("senior")
if not seniors:
# Fallback: kantoor general line, never a voicemail.
return await call.transfer(KANTOOR_LINE)
winner = await race_first_accept(seniors, timeout_s=20)
if not winner:
return await call.transfer(KANTOOR_LINE)
await call.warm_transfer(winner.sip_uri)
Wat we logden en wat niet
AVG dwong een paar beslissingen af die we sowieso hadden genomen. Volledige audio: 7 dagen, encrypted at rest met een sleutel die de broker zelf roteert. Transcripts met gestructureerde PII redactie: 90 dagen. BSN, IBAN en elke string die past binnen een vocabulaire van medische condities wordt bij write-time vervangen door een token. Het acceptant-team werkt vanuit de geredigeerde transcripts en kan via een vier-ogen approval flow een eenmalige unmask aanvragen.
We loggen niet de toon, het sentiment of de emotionele staat van de polishouder. Het model mag daar binnen de call op routen. Het wordt niet bewaard. De compliance officer gaf hier om, de directeur gaf hier om, en wij gaven hier om. Deels uit principe, deels omdat niemand de studio wil zijn waarvan de voice agent een AVG-auditor vertelt dat Maria afgelopen dinsdag angstig klonk.
Wat ons verraste
De 14 jaar oude .NET 4.5 backend was niet het probleem dat we verwachtten. De WCF facade was stabiel, idempotent en netjes benoemd, omdat de originele developer er om gaf. De medewerker-UI was het probleem. Het was het enige pad voor bepaalde writes, en de seniors hadden keyboard shortcuts die de agent niet kon nabootsen zonder de DOM van een thick client te scrapen. Die flows hebben we met rust gelaten en doorgerouteerd naar mensen.
De andere verrassing: de polishouders gaven de voorkeur aan de agent voor de saaie calls. We verwachtten weerstand en kregen NPS-winst. De hypothese van de directeur: mensen willen niet het gevoel hebben dat ze de tijd van een mens verspillen aan een wijziging van vijf minuten.
Het werk zit in de naden
Toen wij deze voice agent bouwden voor de Rotterdamse zorgbroker, was het AI-deel niet het moeilijke. Het moeilijke was de WCF facade die op elke fault 200 OK teruggaf, de Papiamentse BSN-uitvraag, en de SLA van 60 seconden op code-rood. Dat hebben we opgelost door elke SOAP envelope te parsen voor we hem vertrouwden, terug te vallen op DTMF voor elke string van negen cijfers, en de keten van webhooks te vervangen door één persistente socket.
Het kleinste wat je vandaag kunt doen: pak één week aan call-opnames, zet een timer van 90 minuten, en tag elke call met het kleinste stukje informatie dat de agent nodig zou hebben om hem af te ronden. Heeft 40% van je calls minder dan vier datapunten nodig, dan heb je een voice agent project. Heb je er twaalf nodig, dan heb je eerst een CRM project.
Kern
Het werk in een voice agent zit zelden in het taalmodel. Het zit in de integratienaden, de routing-regels, en het escalatiepad waar de SLA daadwerkelijk op leeft.
FAQ
Waarom niet eerst de .NET 4.5 polisadministratie vervangen?
Omdat een werkende voice agent tegen een stabiele WCF facade in weken te leveren is. Een platformmigratie levert in kwartalen en draagt data-integriteit-risico dat de broker er niet bij wilde nemen op het moment dat er ook een nieuw kanaal kwam.
Kan Whisper echt overweg met Papiaments?
Goed genoeg voor intent classificatie en vrije conversatie, niet goed genoeg voor strings van negen cijfers zoals een BSN. Voor elke nummer-uitvraag gebruiken we DTMF keypad invoer, in zowel Nederlandse als Papiamentse calls.
Hoe meten jullie de SLA van 60 seconden op code-rood?
Vanaf de timestamp van het intent classification event in de agent log tot de timestamp waarop de senior medewerker accepteren indrukt op de WebSocket prompt. Beide worden server-side gestempeld op dezelfde Hetzner box.
Wat gebeurt er als er geen senior online is wanneer een code-rood binnenkomt?
De agent transfert naar de algemene kantoorlijn, nooit naar voicemail. Dat fallback-pad wordt ongeveer twee keer per maand gebruikt, wat meer is dan nul maar niet genoeg om het ontwerp er omheen te bouwen.
Hoeveel menselijke review had week één nodig?
Twee acceptanten besteedden in week één ongeveer 90 minuten per dag aan het beoordelen van gemarkeerde transcripts. Tegen week drie was dat gezakt naar ruwweg 20 minuten per dag, vooral op Papiamentse edge cases.