Chat agents
WhatsApp chat agent voor fysioketen: BSN-veilig draaiboek
Een fysioketen in Maastricht verdronk in 2.100 WhatsApp-verplaatsverzoeken per week, verstrengeld met een 12 jaar oude Intramed en een BSN dat juridisch geen Amerikaans datacenter mag raken.

Op een maandag om 08:14 had de teamlead aan de balie van een fysioketen met 24 medewerkers in Maastricht 47 ongelezen WhatsApp-gesprekken openstaan. Drie patronen, steeds opnieuw: "kan ik verzetten naar donderdag?", "ben ziek, moet ik opnieuw plannen?", en "klopt mijn afspraak van morgen nog?". Elk antwoord kostte haar zo'n negentig seconden, omdat ze voor elk gesprek even moest checken in Intramed, het praktijksysteem dat de keten sinds 2014 draait.
Aan het eind van dat kwartaal kwamen er ongeveer 2.100 verzetverzoeken per week binnen op diezelfde inbox. We bouwden een WhatsApp Business agent die het grootste deel daarvan nu afhandelt. Het interessante zat nooit in het model. Het zat in de legacy-integratie, het BSN-probleem en de proxy daartussen. Dit is het draaiboek.
Architectuur in drie blokken
Het systeem is bewust klein. Drie onderdelen:
- Een Cloudflare Worker die elke uitgaande call naar de WhatsApp Cloud API afvangt.
- Een agent runtime op een Hetzner VM in Falkenstein die de session state bijhoudt en de LLM draait.
- Een FastAPI-shim op diezelfde VM die via ODBC met Intramed praat.
Inkomende webhooks van WhatsApp komen eerst binnen bij de Worker en gaan via een ondertekende HTTPS-call door naar de runtime. Uitgaande berichten van de agent leggen de omgekeerde route af. WhatsApp ziet noodzakelijkerwijs het telefoonnummer, want zo bepaalt het kanaal de bestemming van een bericht. Wat het niet ziet is een BSN, een verzekeringsnummer, een geboortedatum of enig vrij tekstveld dat niet via onze tokentabel is afgehandeld. De agent zelf werkt met ondoorzichtige tokens zoals PT_4f7a, die de Worker bij het verlaten van het netwerk terugvertaalt naar leesbare strings.
BSN strippen aan de rand
De juridische druk hier is echt. De Autoriteit Persoonsgegevens behandelt burgerservicenummers als een bijzondere categorie identificatiegegevens onder de UAVG, en een zorgaanbieder die BSN's zonder duidelijke grondslag de Atlantische Oceaan over laat steken zit niet in een prettige toezichtspositie. De WhatsApp Cloud API draait op Meta en de logs staan op Amerikaanse infrastructuur. We bouwden de proxy op de aanname dat elke payload die de Cloud API raakt, in de praktijk gewoon in de Verenigde Staten staat.
De Worker doet drie dingen op het uitgaande pad. Hij valideert een HMAC-signature van de runtime, zodat een lekkende service binnen ons eigen netwerk niet rechtstreeks naar Meta kan posten. Hij zoekt tokens op in een kortlevende KV-namespace en zet ze terug in de body van het bericht. En hij draait een regex-sweep voor negencijferige BSN-achtige nummers, IBANs en verzekeringsprefixen. Negencijferige treffers worden door de elfproef gehaald, dezelfde checksum die de overheid gebruikt om BSN's te valideren. Alles wat slaagt wordt als een echte identifier behandeld en gedropt.
// worker/src/whatsapp-proxy.ts
import { verifyHmac } from "./hmac"
import { isValidBsn } from "./elfproef"
const NINE_DIGITS = /\b\d{9}\b/g
const IBAN = /\bNL\d{2}[A-Z]{4}\d{10}\b/g
export default {
async fetch(req: Request, env: Env): Promise {
if (!await verifyHmac(req, env.RUNTIME_SECRET)) {
return new Response("bad signature", { status: 401 })
}
const msg = await req.json()
const body = await expandTokens(msg.body, env.TOKENS)
const bsnHits = [...body.matchAll(NINE_DIGITS)].filter(m => isValidBsn(m[0]))
if (bsnHits.length || IBAN.test(body)) {
await env.AUDIT.put(crypto.randomUUID(), JSON.stringify({
kind: "leak_blocked",
thread: msg.thread_id,
reason: bsnHits.length ? "bsn" : "iban",
}))
return new Response("sensitive identifier in payload", { status: 422 })
}
return fetch(`https://graph.facebook.com/v20.0/${env.PHONE_ID}/messages`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.WA_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: msg.to,
type: "text",
text: { body },
}),
})
},
}
De 422-response is geen generieke fout. De runtime ziet het als een hard signaal dat de prompt, een template of een tool-result herzien moet worden. In de eerste drie weken sloeg die bounce-regel elf keer aan. Elke keer was het een echt probleem dat we anders gewoon hadden uitgerold: een template die het verkeerde veld interpoleerde, een tool-result waarin een join-rij was meegelekt, een model dat behulpzaam een nummer herhaalde dat de patiënt terloops had genoemd. Geen daarvan heeft Meta bereikt.
Behandel de output van een LLM als niet te vertrouwen. De redactie hoort thuis op de grens, in code die je in een functie van vijf regels kunt lezen, niet in een system prompt.
Aansluiten op Intramed zonder echte API
Intramed bestaat sinds de jaren negentig. De huidige versies hebben een SOAP-partnerinterface, maar onze klant zat op een installatie uit 2014, waar de enige praktische ingang een read-only ODBC-verbinding op de onderliggende Firebird-database was, plus een handvol stored procedures die de leverancier ooit had gedocumenteerd in een pdf die nog altijd op een USB-stick op kantoor lag. We zouden Intramed niet gaan moderniseren. Dat was de verkeerde strijd.
In plaats daarvan schreven we een dunne FastAPI-shim op dezelfde VM. Hij heeft zeven endpoints: find_patient, list_upcoming, list_slots, hold_slot, confirm, cancel en log_note. Elk endpoint is een functie rond een ODBC-query of een stored procedure call. De shim is ongeveer 380 regels Python. Hij heeft eigen integratietests tegen een nachtelijke snapshot van de productie-database, teruggezet in een sandbox-schema, zodat de tests nooit een live regel raken.
De agent praat nooit rechtstreeks met Intramed. Hij praat met de shim. De shim is het contract. Als de keten volgend jaar Intramed upgradet, hoeft alleen de shim herschreven te worden. De agent runtime, de Worker, de prompts en de WhatsApp-templates blijven allemaal staan.
Twee Firebird-eigenaardigheden kostten elk een dag. ODBC connection pooling onder de driver waar we op uitkwamen lekte file descriptors op langlopende processen, dus pinden we een verse connection per request vast en namen de latency-kost voor lief. Diezelfde stored procedures gaven ook VARCHAR-kolommen terug met trailing whitespace, overgebleven uit een nachtelijke fixed-width import, waarop het model anders 'Jansen ' netjes terug naar patiënten echode. Geen van beide problemen is interessant. Allebei zijn typerend voor elke 12 jaar oude praktijksoftware, en allebei horen ze thuis in de shim, want elke toekomstige consument van Intramed loopt er net zo hard tegenaan.
De state machine voor verzetten
Het gesprek is geen vrije vorm. Het is een state machine die de LLM aanstuurt, geen rij generieke chatbeurten. Zes states en een fallback:
# runtime/state.py
class State(str, Enum):
IDENTIFY = "identify" # match phone number to patient
INTENT = "intent" # rebook, cancel, question, other
PROPOSE = "propose" # offer 3 slots from list_slots()
HOLD = "hold" # patient picked, soft-hold for 90s
CONFIRM = "confirm" # send approved WA template
DONE = "done"
HANDOFF = "handoff" # escalate to a human
TRANSITIONS = {
State.IDENTIFY: {State.INTENT, State.HANDOFF},
State.INTENT: {State.PROPOSE, State.HANDOFF, State.DONE},
State.PROPOSE: {State.HOLD, State.PROPOSE, State.HANDOFF},
State.HOLD: {State.CONFIRM, State.PROPOSE, State.HANDOFF},
State.CONFIRM: {State.DONE, State.HANDOFF},
}
De LLM kiest de volgende state uit de toegestane verzameling en schrijft een korte motivatie naar de audit log. Slots die aangeboden maar niet gekozen zijn, komen na 90 seconden weer vrij. Daarmee was de oorspronkelijke race opgelost waarbij twee patiënten kort na elkaar dezelfde donderdag 14:30 vastpakten. Het model verzint geen tijden. list_slots geeft een JSON array terug en de agent moet letterlijk uit die array kiezen, of list_slots opnieuw aanroepen met een ander tijdvenster. De WhatsApp-template voor CONFIRM is vooraf goedgekeurd via Business Manager, dus het bericht gaat de deur uit, ook als de patiënt langer dan 24 uur stil is geweest.
IDENTIFY was de state die we onderschatten. De meeste patiënten appen vanaf hun eigen telefoon, maar ongeveer een op de zeven contacten in de database van de keten heeft een gedeeld gezinsnummer, en een flink deel van de oudere groep schrijft vanaf de WhatsApp van een zoon of dochter. De lookup-tabel houdt (phone, [patient_candidates]) bij. Matcht precies één kandidaat, dan gaan we door. Bij twee of meer matches stelt de agent één bevestigingsvraag over voornaam en geboortejaar. Is het antwoord onduidelijk of leeg, dan dragen we over. Het model mag nooit gokken op identiteit. De prijs van een misser is iemand de afspraaktijd van een ander sturen, en dat is het enige faalscenario dat de keten echt niet kan accepteren.
Waar mensen het overnemen
De agent draagt over in vier situaties: de patiënt stelt een medische vraag, de patiënt heeft een betalingsachterstand, het gesprek loopt vijf beurten zonder vooruitgang, of onze zekerheid over de patiëntmatch zakt onder een vaste drempel. Overdracht is geen beleefd 'ik verbind je door' berichtje. Het is een Slack-ping in het juiste kanaal van de juiste praktijk, met de laatste zes berichten, de weergavenaam van de patiënt (geen BSN) en een deeplink naar Intramed. Het praktijkteam pakt het daarna op.
Ongeveer een op de zeven gesprekken eindigt in een overdracht. Op dat getal sturen we bij. Zakt het te ver, dan grijpt de agent waarschijnlijk te ver. Loopt het op, dan moeten de prompts of de slot-logica eraan.
Hoe de eerste zes weken eruitzagen
De agent ging op een dinsdag om 11:00 live, achter een feature flag die hem de eerste tien dagen beperkte tot één praktijk in Maastricht-Centrum. De teamlead daar kreeg het Slack-overdrachtkanaal en een expliciet veto: alles wat raar leek, trok ze terug in haar eigen inbox, en wij behandelden haar terugkoppeling als de bug report die telde. Op vrijdag had ze WhatsApp Web niet meer open op haar tweede scherm. Aan het eind van week drie was de mediane tijd per gesprek, van eerste bericht tot bevestigingstemplate, gezakt van ruim vier minuten naar onder de vijftig seconden. In week vier rolden we de agent uit naar de overige drie praktijken. De getallen waar we echt naar kijken:
- Gesprekken die zonder mens van begin tot eind worden afgehandeld, rond de 84%.
- Worker 422-bounces (gevoelige payload geblokkeerd), 0 in de afgelopen twee weken tegen 11 in de eerste drie weken.
- Patiëntklachten herleidbaar tot de agent, één, over toon, opgelost in de system prompt.
- Vrijgespeelde balie-uren per week, volgens de eigen telling van de teamlead, ongeveer 28.
Het nuttigste neveneffect had niets met WhatsApp te maken. De audit log die we voor de proxy bouwden, bleek het schoonste overzicht dat de keten ooit had gehad van patiëntcommunicatie. De Functionaris Gegevensbescherming trok hem tijdens de volgende kwartaalreview erbij en tekende het WhatsApp-kanaal af als een beheerste verwerking binnen de bestaande DPIA. Dat was het moment dat het project geen pilot meer was.
De audit van vijf minuten
Draai je iets vergelijkbaars? Open dan het netwerkpaneel van wat je berichten richting WhatsApp, Telegram of een ander extern kanaal stuurt. Kijk naar de ruwe JSON van de eerstvolgende tien uitgaande payloads. Zoek op negencijferige getallen, IBANs en e-mailadressen. Vind je iets waarvan je niet wil dat een toezichthouder het over een jaar leest, dan heb je een redactieprobleem, en de fix is een proxy, geen prompt. Toen wij dit bouwden voor de keten in Maastricht, zat de verrassing niet in de BSN-lekken die we verwachtten. Het was een stored procedure die het verzekeringsnummer van de patiënt teruggaf in een commentaarveld waar niemand aan had gedacht. Dat soort bug vangt een proxy en een prompt nooit. Wil je zien hoe wij dit soort chat agents tegen legacy systemen aanleggen, dan is de rest van de site het langere antwoord.
Kern
Raakt jouw chat agent Nederlandse zorgdata, dan hoort redactie in proxycode op de grens, niet in de LLM-prompt.
FAQ
Waarom een Cloudflare Worker als proxy en niet redaction in de agent runtime zelf?
Een proxy zit op de grens waar het echt om gaat. Zelfs als de runtime, de prompt of een tool-result lekt, vangt de Worker het op voordat Meta het logt. De runtime is van binnenuit te makkelijk te omzeilen.
Kan de LLM het BSN van de patiënt zien?
Nee. De runtime lost gevoelige velden server-side op en geeft het model ondoorzichtige tokens zoals PT_4f7a. Het BSN zit nooit in de context van het model, dus het kan het ook niet teruggeven in een bericht, zelfs niet op verzoek.
Wat gebeurt er als het 24-uurs berichtenvenster van WhatsApp dichtgaat?
Bevestigingen gaan uit als een vooraf goedgekeurde utility-template uit Business Manager. Templates vallen buiten het 24-uurs klantenservicevenster, dus het bericht bereikt de patiënt ook na een langere stilte.
Waarom niet eerst van Intramed af?
Verkeerde strijd. Een dunne FastAPI-shim over ODBC leverde ons in twee weken een stabiel contract op. De keten kan Intramed in zijn eigen tempo vervangen, en alleen de shim hoeft op dat moment herschreven te worden.