← Blog

Chat agents

WhatsApp-agent op een legacy Planon: het hele draaiboek

Het is donderdagavond 22:14 en een huurder in Tilburg stuurt een WhatsApp over een lekkende radiator. De werkbon moet vóór middernacht in Planon staan.

Jacob Molkenboer· Oprichter · A Brand New Company· 1 apr 2025· 10 min
Roomkleurige werkbon met groene sticky, koperen bel, linnen touw en leren map op beige bureaulegger.

Het is donderdagavond 22:14 en een huurder in een Tilburgs wooncomplex appt zojuist via WhatsApp: "kraan in keuken lekt, water staat al op de vloer". De planner met dienst slaapt. De nachtploeg-contractor heeft een telefoon, maar geen laptop. Het facilitair beheersysteem van het onderhoudsteam, een Planon-omgeving die voor het eerst in 2014 werd uitgerold, accepteert geen werkbon zolang de huurder-ID, locatiecode, asset, prioriteit en SLA-venster niet allemaal correct zijn ingevuld. De SLA-klok start op het moment dat het incident-object wordt aangemaakt. Maakt niemand dat object aan vóór middernacht, dan schuift het contractuele responsvenster door naar de volgende kalenderdag en krijgt de woningcorporatie een klacht die ze niet verdient.

Dit is het verhaal van hoe we de chat-agent bouwden die dat gat dicht voor een facilitaire dienstverlener van 37 mensen, ongeveer 1.180 huurder-onderhoudsverzoeken per week verwerkt, en die allemaal rechtstreeks wegschrijft in het twaalf jaar oude Planon FMIS, zonder dat de planner ooit iets hoeft over te typen.

De randvoorwaarde waarover niet te onderhandelen viel

De opdracht was helder. Planon blijft. De klantcontracten, KPI-rapportages, SLA's met onderaannemers en de financiële exports van de groep draaien allemaal op hetzelfde FMIS. Een vervangingstraject was begroot op ruim een half miljoen euro en driekwart van de mensuren. Niemand had daar trek in. Dus moest de AI-agent in het legacy-systeem schrijven, niet eromheen.

De versie waar we mee te maken hadden, dateerde van vóór de moderne Planon REST APIs. Er was een SOAP-webservice, een gedeeltelijk Planon Connect-endpoint, een read replica van de database die we mochten benaderen, en de formele Planon AppSuite XML-interface voor het aanmaken van incidenten. Het interne team bestond uit één Planon-beheerder die het schema dromen kende en één parttime Java-developer die de maatwerkcode sinds 2018 in leven hield. Meer hadden we niet om mee te werken.

Twee harde eisen kwamen van de operations director. Elk WhatsApp-bericht moest als incident-object in Planon belanden (geen shadow database), en de SLA-klok moest die zijn die Planon zelf al berekende. Geen parallelle timer in de agent. De auditor zou nooit twee klokken accepteren.

De architectuur in één alinea

De pijplijn loopt in vier stappen. Een huurder stuurt een WhatsApp-bericht naar het Business-nummer van de dienst. De WhatsApp Cloud API levert dat af bij Twilio Conversations, dat ons één samenhangende inbox geeft over WhatsApp, SMS en de bestaande webchat-widget. Onze agent-middleware (een TypeScript-service op de eigen VPC van de klant) leest de thread, draait de classifier en slot-filler, en bepaalt of het Planon-incident automatisch wordt aangemaakt, of er één verduidelijkende vraag gesteld wordt, of dat het gesprek wordt overgedragen aan een mens. De middleware POST't het incident naar Planon via de AppSuite XML-interface. Planon stuurt het incidentnummer terug. De middleware schrijft dat nummer als systeembericht terug in de Twilio-conversatie, zodat elke collega die later instapt het case-ID bovenaan ziet staan.

Waarom Twilio Conversations en niet de WhatsApp Cloud API direct? Omdat de groep al Twilio draaide voor uitgaande SMS-herinneringen en de nachtdispatcher Twilio Flex gebruikte voor spraak. Door WhatsApp in dezelfde Conversations-resource te stoppen, kon de planner een thread oppakken op een tablet, kon de nachtchef het zien in Flex, en hoefden we geen nieuwe inbox-UI te bouwen. Het samengestelde Conversations-object staat gedocumenteerd op twilio.com/docs/conversations en is een van de weinige gevallen waarin de vendor-abstractie de per-bericht-vergoeding waard is.

Huurders herkennen zonder dat ze hoeven in te loggen

De lastigste UX-beslissing was authenticatie. Huurders klikken niet op links. Ze tikken geen huurder-ID's in. Ze loggen niet in op een portaal. Ze fotograferen een lekkende leiding en sturen die met het bijschrift "huis nummer 14 het lekt weer". De agent moet zelf uitvogelen wie ze zijn, zonder dat het gesprek aanvoelt als een formulier.

Drie signalen doen het meeste werk. Het WhatsApp-telefoonnummer, gematcht tegen het contactrecord in de huurdertabel van Planon. Vrije-tekst adresparsing op de velden Adres, Huisnummer en Postcode die al op het huurder-masterrecord staan. Een terugvalvraag, alleen als de eerste twee niets opleveren, naar de viercijferige Planon-huurdercode die op elke factuur staat. Zo'n 92% van de inkomende berichten matcht in één keer op telefoonnummer. De adresparser vangt nog eens 6% af (huisgenoten met een andere telefoon). De resterende 2% komt bij de viercijferige fallback uit. We vragen nooit naar volledige namen. Privacy en wrijving-bij-typen-op-een-scherm zijn in dit product hetzelfde probleem.

De intent-laag

De classifier is klein. Er zijn maar zeven intent-klassen die voor een facilitaire dienst tellen: nieuw onderhoudsverzoek, update of rappel op een lopende case, toegangs- of sleutelverzoek, klacht of kwaliteitsissue, vraag over facturatie of huur (gaat direct naar een ander team), noodgeval (gas, water, elektriciteit, vastzittende lift), en alles daarbuiten (smalltalk, verkeerd nummer, oplichting).

We gebruiken één LLM-call per inkomend bericht met een structured-output schema. Het schema geeft intent, confidence, geëxtraheerde slots (locatie, asset-type, ernst, taal) en een refusal-vlag terug. Zakt de confidence onder 0,78, dan stelt de agent één verduidelijkende vraag en classificeert opnieuw. Twee verduidelijkingen en het gesprek wordt doorgeroute naar een mens. We hebben dat op twee afgekapt omdat we in productielogs zagen dat elke agent die een derde vraag stelde de huurder kwijtraakte. Die legt de telefoon weg en belt de balie, en dat is precies het faalpatroon waarvoor we waren ingehuurd om weg te halen.

Voor de noodklasse draait de LLM-call nog steeds, maar zit verpakt in een regex-guardrail. Elk bericht dat ook matcht met een lijst triggerwoorden ("gas", "rook", "spuit", "geen stroom", "vast in de lift", enzovoort) gaat direct naar de on-call dispatcher in Flex, zonder op het model te wachten. Het model heeft hierin ongeveer 99% van de tijd gelijk, maar 99% is niet goed genoeg als het faalscenario een gaslek is. Guardrails op het snelle pad zijn geen smaakkwestie. De recente reeks voorpaginaverhalen over agents die los gaan in productiesystemen is een redelijke jaarlijkse herinnering: een agent met de juiste tool calls en een slordige classifier kan echte schade aanrichten. Wij behandelen de Planon-write als een privileged action en bewaken die zoals je een sudo zou bewaken.

Het incident-object goed wegschrijven

Dit is het stuk waar we onze rekening verdienden.

Het incident-object van Planon heeft ruwweg veertig velden. Een stuk of vijftien daarvan zijn verplicht als je wilt dat de SLA-engine de klok automatisch start. Zit er eentje fout, dan accepteert Planon het record wel, maar slaat de SLA-evaluatie over. Het rapport aan het eind van de maand laat dan elk door de chat-agent aangemaakt incident zien met een lege responstijd. De Planon-beheerder ziet dat op dag drie en het hele project sterft.

We hebben elk verplicht veld gekoppeld aan een deterministische bron. De huurder-ID kwam uit de match op telefoonnummer. De locatie kwam uit het primaire adresrecord van de huurder. De asset-code mocht null zijn, en werd alleen ingevuld als de LLM er een uit het bericht haalde met een confidence boven 0,9. De prioriteit werd afgeleid uit het severity-slot, gemapt via de klant-specifieke prioriteitsmatrix (de woningcorporatie hanteert 1 tot en met 4, het scholenportfolio 1 tot en met 5; de matrix staat per contract). De SLA werd opgezocht in het contract-object, nooit geraden. De report date was de servertijd op het moment van schrijven. De omschrijving was de schone Nederlandse samenvatting van de LLM plus een letterlijk verbatim van het eerste huurderbericht en eventuele afbeeldingsbestandsnamen.

function composeIncident(msg, tenant, contract) {
  const priority = priorityMatrix[contract.mandantCode][msg.severity];
  return {
    BO_TENANT_ID: tenant.id,
    BO_LOCATION: tenant.primaryLocationId,
    BO_ASSET: msg.assetCode ?? null,
    BO_PRIORITY: priority,         // computed, never from LLM
    BO_SLA: contract.slaId,        // computed, never from LLM
    BO_REPORT_DATE: new Date().toISOString(),
    BO_DESCRIPTION: `${msg.cleanSummary}\n\nVerbatim: ${msg.raw}`,
    BO_MANDANT: contract.mandantCode,
  };
}

We laten de LLM bewust geen prioriteit of SLA kiezen. Het model schrijft de omschrijving en de asset-hint. Alles wat aan de contractuele klok raakt, wordt berekend uit joins in de middleware. Als we ooit een gemiste SLA bij de klant moeten verdedigen, moet het spoor in één scherm uit te leggen zijn. "De LLM dacht dat het een P2 was" is geen verdedigbaar antwoord.

Let op

Laat de LLM nooit de contractuele prioriteit kiezen. Map die via de bestaande prioriteitsmatrix van de klant, op data die al in het FMIS staat. De audit trail is het product.

Twilio Conversations als escalatiebaan

Escalaties gebeuren om drie redenen. De classifier is twee keer onzeker, de intent is "klacht", of de SLA-klok zakt onder een grens (wij gebruiken 25% resterend) en de case staat nog op status "nieuw".

In alle drie de gevallen plaatst de agent een Conversations-systeembericht waarin de reden staat, pingt de juiste Twilio-queue (overdag de planners, 's nachts de on-call dispatcher), en laat de WhatsApp-thread openstaan. De mens pakt het op in diezelfde thread. De huurder ziet geen breuk. Vanaf de huurderkant is "het bedrijf" één doorlopend chatvenster. Vanaf onze kant weten we precies welke berichten door het model zijn gegenereerd en welke door een mens, want elk modelbericht krijgt een verborgen attribuut mee, x-abn-author: agent-v3.1, dat opduikt in de audit-export.

Iets subtiels over Twilio Conversations: het 24-uurs customer-care venster van WhatsApp blijft gelden, ook als een mens overneemt. Antwoordt de planner meer dan 24 uur na het laatste inkomende bericht, dan wordt dat bericht gedropt, tenzij het uitgaat als goedgekeurde template. De middleware houdt het venster per conversatie bij en schakelt uitgaande berichten stilletjes om naar een template zodra het verloopt. De WhatsApp Cloud API messaging guide van Meta is hier de bron van waarheid en is het lezen waard voordat je hier iets serieus mee in productie zet.

Guardrails, weigeringen en de audit trail

Drie guardrails zijn het beschrijven waard.

Ten eerste: elke Planon-write wordt voorafgegaan door een dry-run validatie tegen hetzelfde XML-schema dat Planon intern gebruikt. Faalt de dry-run, dan escaleert het bericht naar een mens en probeert de agent het nooit opnieuw met aangepaste input. We laten het model nooit de XML "fixen". De write komt door de validatie zoals samengesteld door deterministische code, of hij gaat niet.

Ten tweede: het model heeft geen tool die een eenmaal weggeschreven incident kan wijzigen of sluiten. De chat-agent kan alleen nieuwe incidenten aanmaken of commentaar plaatsen. Statuswijzigingen (geaccepteerd, in behandeling, gesloten) zijn voorbehouden aan de planner-UI. Een chat-agent die zijn eigen tickets kan sluiten, is een chat-agent die zijn eigen metrics gaat optimaliseren.

Ten derde: elke model-call wordt gelogd, met volledige prompt, modelversie, ruwe response, latency en kosten, in een Postgres-tabel waarvan de klant eigenaar is en die de klant zelf leest. De retentie wordt bepaald door hun DPO, niet door ons. Gezien de huidige publieke discussie over verplichte minimum-retentie aan vendor-zijde is "wij houden de logs in eigen hand" vanaf dag één de enige verstandige standaard. We zetten zero-day retention aan bij de modelvendor en schrijven onze eigen logs naar de database van de klant.

Voor de orchestratielaag zelf hebben we gekeken naar Burr van DAGWorks voor het state-machine-perspectief. We draaien het in dit project niet (de agent-loop is klein genoeg dat een handgeschreven state machine leesbaarder is), maar voor een conversationelere agent met vertakkingen verdient een graph-framework zich terug zodra je voor het eerst een vastgelopen gesprek moet debuggen.

Wat de eerste acht weken veranderde

Cijfers uit het dashboard van de klant zelf, niet uit dat van ons.

  • Mediane tijd van huurderbericht tot aangemaakt Planon-incident: van 14 minuten (planner met de hand) naar 38 seconden (agent).
  • Aandeel buiten-kantoor-tijd huurderverzoeken dat met een correct SLA-venster al gekoppeld in de wachtrij van de volgende ochtend belandt: van 41% naar 96%.
  • Planner-uren per week die eerder opgingen aan triage-typewerk: ruwweg 22 uur, doorgeschoven naar de cases die wél een mens nodig hebben.
  • Klachtpercentage ("niemand heeft me teruggebeld") op buiten-kantoor-tijd berichten: ongeveer gehalveerd, gemeten in het maandelijkse KPI-rapport van de woningcorporatie.

We beweren niet dat de agent planners sneller maakt. Hij haalt typewerk weg dat ze nooit hadden moeten doen. Dat is een andere en eerlijkere claim.

De vijf-minuten audit die je morgen kunt doen

Heb je een vergelijkbare situatie, dan is de goedkoopste eerste stap een veld-niveau audit op je eigen FMIS of CRM. Print de verplichte velden op het incident- of ticket-object af, ga een uur naast een planner zitten, en kijk waar ze vandaan plakken. Waar ze plakken, heb je een kandidaat die de agent deterministisch kan wegschrijven. Waarschijnlijk kom je erachter dat de SLA-klok het veld is waar iedereen het meest van schrikt. Map die terug op een contractregel in plaats van een model-gok, en het project wordt een stuk minder eng.

Toen we de chat-agent voor de Tilburgse groep bouwden, was wat we niet hadden zien aankomen dat de XML-interface van Planon een net iets andere prioriteitscode per klant-mandant accepteert, en dat één enum één positie verschoof tussen de mandant van de woningcorporatie en die van de scholen. We hebben dat opgelost door eerst de mandant-code uit het huurder-record te lezen voordat we de XML samenstelden, wat ons een week heeft gekost en grotendeels het werk is bij het bouwen van AI-agents tegen systemen die gebouwd zijn voordat er een API was om in te schrijven.

Kern

Laat de chat-agent werk aanmaken, nooit afsluiten. Map de SLA-prioriteit via de bestaande matrix van de klant, niet via het model. De audit trail is het product.

FAQ

Sluit de agent ooit zelfstandig een ticket?

Nee. De chat-agent kan incidenten aanmaken en commentaar toevoegen. Statuswijzigingen (geaccepteerd, in behandeling, gesloten) blijven bij de planner-UI. Een agent die zijn eigen werk kan afsluiten, optimaliseert zijn eigen metrics.

Hoe ga je om met het 24-uurs customer-care venster van WhatsApp?

De middleware houdt het venster per conversatie bij. Zodra het verloopt, schakelen uitgaande antwoorden van mensen automatisch om naar een goedgekeurd templatebericht, totdat de huurder weer iets stuurt.

Wat gebeurt er als Planon onbereikbaar is?

De agent bevestigt richting de huurder, zet het incident in een lokale outbox en probeert het opnieuw met backoff. De report date die uiteindelijk in Planon wordt geschreven is het oorspronkelijke berichttijdstempel, dus de SLA-klok haakt correct aan zodra Planon weer terug is.

Waarom Twilio Conversations en niet direct de WhatsApp Cloud API?

De planners, de nachtdispatcher en de bestaande SMS-herinneringen draaiden al in Twilio. Eén inbox per huurder over WhatsApp, SMS en webchat is de per-bericht-vergoeding waard.

Kiest de LLM ooit de SLA-prioriteit?

Nee. Het model haalt een ernst-hint uit het bericht. Prioriteit en SLA worden in de middleware berekend uit de klant-specifieke matrix en het contract-object. Alles wat aan de contractuele klok raakt, moet verdedigbaar zijn zonder het model.

chat agentsai agentsintegrationsworkflowcase studyoperations

Iets bouwen?

Start een project