Email automation
AI-agent voor inbox-triage: 1.400 mails per dag routeren
Een Zwols adviesbureau met 19 medewerkers verdronk in burgermail. Dit is de architectuur, het schema en de vijf dingen die het systeem bijna sloopten.

De stagiair met de spreadsheet
Dinsdag, 08:47. De gedeelde inbox op het Zwolse kantoor van onze klant telt 643 ongelezen berichten. Een stagiair heeft een spreadsheet open met een kolom soort. Ze leest elke mail, typt iets als WMO-bezwaar of bijzondere bijstand in de cel, en stuurt het bericht door naar de behandelaar die dat type vandaag oppakt. Tegen vrijdag is haar spreadsheet het routeringsschema geworden.
Het bureau heeft negentien medewerkers. Ze adviseren burgers die te maken hebben met besluiten van de gemeente: afgewezen zorgaanvragen, geschillen over bijstand, WOZ-bezwaren, schuldhulpverlening. De inbox krijgt gemiddeld 1.400 mails per werkdag, met pieken rond de 2.100 in de kwartaalcycli van de Belastingdienst. De spreadsheet van de stagiair is het enige wat de boel gesorteerd houdt. Is ze ziek, dan groeit de wachtrij.
Het bureau vroeg ons om één ding. Haal de spreadsheet uit de keten. Geen chatbot. Geen automatische antwoorden. Wel dezelfde routeringsbeslissingen als zij nam, maar in minder dan tien seconden en zonder mens in het midden.
Dit is wat we hebben opgeleverd, en wat we fout deden.
Wat 31 zaaksoorten in de praktijk betekenen
Voordat je ook maar een model aanraakt, moet je beslissen wat de categorieën zijn. Dat klinkt triviaal. Is het niet.
De spreadsheet had aan het begin van het project 47 verschillende labels. Sommige waren echte zaaksoorten (WMO-bezwaar). Sommige waren statusnotities (wachten op stukken). Sommige waren typfouten (WMO-bezwaaar). Twee ervan, overig en weet niet, waren samen goed voor 18% van de rijen.
De eerste week schreven we geen code. We zaten met de senior behandelaar en de kantoormanager de lijst door. Zeven WMO-varianten brachten we terug naar drie. Bezwaarschrift splitsten we in vier subtypes, omdat de routering echt verschilde. Overig hebben we volledig geschrapt, want het hele punt van het systeem is dat het weigert overig te gebruiken.
We kwamen uit op 31. Het schema zag er zo uit:
case_types:
- id: WMO_INDICATIE_BEZWAAR
label_nl: "Bezwaar tegen WMO-indicatie"
description_nl: |
Burger maakt formeel bezwaar tegen een door de gemeente
afgegeven WMO-indicatie. Vaak gaat het om afgewezen
huishoudelijke hulp, verlaagde uren, of geweigerde dagbesteding.
keywords: [WMO, indicatie, bezwaar, huishoudelijke hulp, afwijzing]
negative_examples:
- "Vraag over WMO-aanvraag"
- "Wanneer komt de thuiszorg langs?"
sla_hours: 48
requires: [WMO_specialist]
Elke zaaksoort heeft een SLA, een tag voor de vereiste specialist, en een kleine set negatieve voorbeelden. De negatieve voorbeelden tellen net zo zwaar als de positieve. Vraag over WMO is geen bezwaar. Mijn moeder belde mij gisteren is helemaal geen zaak.
Als je één ding meeneemt uit dit stuk: het schema is het product. Een model dat tegen een slordig schema draait, levert slordige beslissingen op, ongeacht hoe groot dat model is.
Waarom embeddings hier een fine-tuned classifier verslaan
Onze eerste reflex was om een Nederlandse BERT-classifier te fine-tunen op een paar duizend gelabelde mails. Dat zou gewerkt hebben. Het zou binnen vier maanden ook verrot zijn, want gemeentetaal verschuift (nieuw beleid, nieuwe Kamerbrieven, nieuwe afkortingen) en het bureau wilde niet elk kwartaal opnieuw trainen.
We kozen in plaats daarvan voor een retrieval-and-judge pipeline in twee fases. Fase één: embed elke binnenkomende mail en zoek de vijf dichtstbijzijnde historische zaken. Fase twee: geef die vijf zaken plus de nieuwe mail aan een LLM en vraag welke zaaksoort past.
De voordelen zijn niet subtiel. Een nieuwe zaaksoort toevoegen is een YAML-bestand bewerken en drie voorbeeldmails labelen. De trainingsdata zijn gewoon de eigen historie van het bureau. En omdat elke beslissing de historische zaken citeert waarop ze is gebaseerd, kan de senior behandelaar een foute call in een seconde of vijftien auditen.
Het nadeel is latency. Twee model-calls per mail kost je een seconde of drie. Daar komen we op terug.
De pipeline in twee fases
De pipeline draait op een kleine Hetzner-VM in Falkenstein. EU-dataopslag was geen onderhandelingspunt voor burgermail. Postgres voor state, pgvector voor embeddings, een Python-worker die luistert naar change notifications van Microsoft Graph.
De classifier ziet er, tot op het bot uitgekleed, zo uit:
def classify_email(email: ParsedEmail) -> Decision:
# Stage 1: retrieve nearest neighbours
query_vec = embed(f"{email.subject}\n\n{email.body_text[:4000]}")
neighbours = db.execute("""
SELECT id, case_type, subject, body_snippet, decided_by
FROM cases
WHERE embedding <#> %s < 0.35
ORDER BY embedding <#> %s
LIMIT 5
""", (query_vec, query_vec)).fetchall()
if not neighbours:
return Decision(case_type=None, confidence=0.0, reason="no_neighbours")
# Stage 2: judge against the schema
prompt = build_judge_prompt(email, neighbours, CASE_TYPES)
verdict = llm.complete(
prompt,
temperature=0,
response_format={"type": "json_object"},
)
parsed = json.loads(verdict)
return Decision(
case_type=parsed["case_type"],
confidence=parsed["confidence"],
reason=parsed["reason"],
cited_cases=[n.id for n in neighbours],
)
De judge-prompt is in het Nederlands. Het model ziet de mail, de vijf buren, het volledige schema en één instructie: kies een zaaksoort of weiger. Het weigerpad is de belangrijkste regel code in het hele systeem.
Routeren is lastiger dan classificeren
Classificeren is de makkelijke helft. Zodra je weet dat een mail een Bezwaar tegen WMO-indicatie is, moet je nog steeds antwoord geven op: wie behandelt het vandaag?
Een bureau van negentien heeft in de praktijk zo'n zes behandelaars die een WMO-bezwaar geloofwaardig kunnen oppakken. Twee daarvan zijn ook senior, wat betekent dat zij de complexe zaken doen. Één werkt parttime. Één is deze week op vakantie. Één begeleidt een junior die deze maand zwaardere zaken nodig heeft.
We modelleerden de routering als een kleine constraint solver over een tabel:
CREATE TABLE routing_state (
caseworker_id uuid PRIMARY KEY,
active_today boolean NOT NULL,
specialisms text[] NOT NULL,
weekly_load int NOT NULL,
capacity_weekly int NOT NULL,
prefer_complex boolean NOT NULL DEFAULT false
);
-- pick the cheapest available caseworker for this case type
SELECT caseworker_id
FROM routing_state
WHERE active_today
AND $1 = ANY(specialisms)
AND weekly_load < capacity_weekly
ORDER BY weekly_load ASC, prefer_complex DESC
LIMIT 1;
De kantoormanager werkt active_today en weekly_load elke ochtend bij via een klein adminpaneel. Dat ritueel van vijf minuten vervangt de spreadsheet van de stagiair.
We hebben overwogen om de agent beschikbaarheid uit Outlook-agenda's te laten afleiden. Geprobeerd. Te slim en te broos. De kantoormanager wilde liever een knop die ze kon indrukken.
Het ontsnappingsluik waar niemand het over heeft
Elke triage-agent die we ooit hebben opgeleverd heeft één feature die de demo nooit laat zien: een wachtrij voor “ik weet het niet”.
Als de confidence van de classifier onder 0,75 zit, of als er geen historische buur binnen afstand 0,35 ligt, of als twee topzaaksoorten binnen 0,05 van elkaar zitten, gaat de mail naar een needs_human queue in plaats van gerouteerd te worden. De senior behandelaar werkt die rij twee keer per dag weg. Elke afhandeling is op zichzelf een gelabeld voorbeeld dat terugvloeit in de embedding store.
In maand één belandde 22% van de mails in de queue. In maand drie was dat 6%. Het model is nooit slimmer geworden. Het schema en de voorbeeldenbank wel.
Als je triage-agent geen “stuur door naar mens” uitkomst heeft, is het geen triage-agent. Het is een auto-deleter met een betrouwbaarheidsinterval.
Koppelen met Microsoft 365
De inbox van het bureau staat in Exchange Online. De nette manier om op nieuwe mail te reageren is de change-notifications API van Microsoft Graph. Die schiet een webhook af elke keer dat er iets verandert in de bewaakte mailbox.
POST https://graph.microsoft.com/v1.0/subscriptions
Content-Type: application/json
{
"changeType": "created",
"notificationUrl": "https://triage.client.nl/graph/notify",
"resource": "users/info@client.nl/mailFolders('Inbox')/messages",
"expirationDateTime": "2026-06-12T18:00:00Z",
"clientState": "shared-secret-here"
}
Subscriptions verlopen na ongeveer drie dagen en moeten vernieuwd worden. We draaien een cron die vernieuwt op 50% van de lifetime, met een fallback poll elke vijf minuten voor het geval een webhook wegvalt. De Graph-docs beschrijven de lifecycle van change-notifications uitvoeriger dan leuk is om te lezen, maar het renewal-patroon is niet onderhandelbaar.
Eén gotcha is het noemen waard: Graph-notifications bevatten niet de body van de mail. Je krijgt een message-ID. De body haal je op met een tweede call. Wil je lagere latency, prefetch dan de body in de webhook-handler voordat je de classificatie-job in de wachtrij zet.
Acht seconden, end to end
De SLA in het contract was tien seconden van “mail komt binnen in de inbox” tot “mail is gecategoriseerd, toegewezen, en er is een Teams-melding naar de behandelaar gestuurd”. In de praktijk draaien we op een mediaan van 7,4 seconden, met p95 op 11,2.
Het budget is als volgt opgebouwd:
- Webhook-aflevering vanaf Graph: 1,5 s mediaan (hier kun je niets aan doen)
- Body ophalen: 0,4 s
- Embedding-call: 0,6 s
- Postgres-buurquery: 80 ms
- LLM-judge-call: 3,8 s
- Routerquery en toewijzing schrijven: 50 ms
- Teams-melding plaatsen: 0,9 s
De LLM-call is de grootste post en de plek waar we blijven itereren. Kleinere, snellere, EU-gehoste modellen die Nederlands aankunnen zijn aan een inhaalslag bezig. Een nieuwere variant inwisselen in maart scheelde ongeveer een seconde op onze mediaan. Op Hacker News klonk deze week een koor dat de vooruitgang in AI afvlakt. Aan de frontier zal dat misschien kloppen. Vanaf waar wij zitten, smalle agents uitrollen in operationele teams, levert het saaie midden van de curve nog elk kwartaal meetbare winst op.
Wat we fout deden
Drie dingen, op volgorde van hoe hard ze beten.
Eén: we behandelden het schema te vroeg als vaststaand. Na zes weken vroeg de senior behandelaar ons zachtjes om een 32e zaaksoort toe te voegen voor een nieuw beleidsspoor. Wij hadden 31 op twee plekken hard-coded. Dat doen we niet meer.
Twee: we onderschatten het belang van de kantoormanager. Zij is de enige die op dinsdagochtend al weet dat twee behandelaars donderdag op een rechtszitting zitten. Haar adminpaneel goed bouwen (drie velden, geen scrollbar, slaat op bij blur) maakte de routering kloppend. Slecht bouwen maakte de routering verkeerd op een manier die op een modelfout léék.
Drie: we lieten het bureau te vroeg de confidence-scores zien. Ze begonnen het model te tweede-raden op zaken waar de confidence 0,81 was, maar ze “een gevoel hadden”. We verborgen de score op het toewijzingsscherm en hielden hem alleen op de auditorweergave. Het vertrouwen ging omhoog.
Toen we deze Nederlandse email automation voor het Zwolse bureau bouwden, was wat we steeds onderschatten dat het model het kleine onderdeel was. Het schema, de routeringstabel en het menselijke ontsnappingsluik droegen het systeem. De LLM was een component die we konden vervangen.
Sta je op het punt om zoiets uit te rollen? Het kleinste nuttige wat je vandaag kunt doen: open je gedeelde inbox, scroll twee weken terug, en label elk bericht met de hand in de zaaksoorten die je denkt te hebben. Bij een derde zit je ernaast. Dat is het project, nog vóór één regel code.
Kern
Het schema en de routeringstabel dragen een inbox-triage agent. Het model is een component die je kunt vervangen.
FAQ
Waarom niet gewoon een Nederlandse classifier fine-tunen?
Fine-tuned classifiers verroten zodra de beleidstaal verschuift. Embedding-retrieval plus een LLM-judge laat je een zaaksoort toevoegen door drie mails te labelen, en elke beslissing citeert de historische zaken waarop ze is gebaseerd.
Hoe houd je burgerdata binnen de EU?
We hosten de worker op een Hetzner-VM in Falkenstein, routeren naar een LLM-provider die alleen in de EU draait, en houden embeddings en zaakteksten in Postgres op dezelfde VM. Geen burgercontent verlaat de EU.
Wat gebeurt er als de agent niet zeker is?
Alles onder 0,75 confidence, of waar twee zaaksoorten binnen 0,05 van elkaar zitten, gaat naar een needs_human queue. De senior behandelaar werkt die twee keer per dag weg, en elke afgehandelde zaak wordt zelf weer een gelabeld voorbeeld.
Hoe lang duurde de bouw van begin tot eind?
Ongeveer tien weken. De eerste drie waren schema-werk zonder code. Zes weken bouwen en parallel meedraaien naast de spreadsheet van de stagiair. Eén afsluitende week in shadow mode voordat de spreadsheet er definitief uit ging.