Chat agents
Chat-agent voor accountants: live binnen een M365-tenant
Een Mechels kantoor van 29 mensen had partners die portaalvragen wegwerkten na het avondeten. We bouwden een chat-agent die er 980 per week afhandelt zonder een Wwft-melding te laten ontsnappen.

Het is 21:47 in Mechelen. De partner-on-call van een accountantskantoor met 29 mensen ziet haar telefoon weer trillen. De wachtrij in het klantportaal staat op 41 onbeantwoorde vragen van vandaag. De meeste gaan over btw-timing op Q2-bonnen. Een paar over jaareindevoorzieningen. Eén noemt een contante storting van de zwager van een bestuurder die ze liever niet half slaap verkeerd interpreteert. De chat-agent die we voor dat kantoor bouwden vangt nu 71% van die vragen op voordat zij ze ooit ziet.
We noemen het kantoor niet in dit stuk (de briefing was direct: schrijf de bouw op, laat de naam weg op de screenshot). Wat we wel kunnen beschrijven is de vorm van het werk, want de architectuur is overdraagbaar naar elk middelgroot accountantskantoor dat Visma eAccounting en Yuki naast elkaar draait op Microsoft 365.
De intake voordat we één regel code schreven
Het kantoor vroeg niet om een chatbot. Ze vroegen om een manier om een portaalbacklog weg te werken die de 980 vragen per week voorbij was. Drie associates verbrandden hun avonden aan dezelfde zes vraagsoorten. Partners scanden op Wwft-meldingen nadat de kinderen naar bed waren. Het verloop onder tweedejaars associates was hoog genoeg dat twee van de vier het afgelopen jaar waren vertrokken, met specifiek de portaalwachtrij als reden.
De eerste twee weken hebben we gelezen. Geen tools geconfigureerd, geen modellen gebenchmarkt. Lezen. Acht weken aan historische portaalgesprekken, geanonimiseerd op de laptop van een partner, geëxporteerd als JSONL. We hebben er 1.640 met de hand getagd. Zes categorieën dekten 84% van het volume:
- Btw-timing en kwartaalaangiftes (31%)
- Vragen over bonnen en factuuruploads (22%)
- Jaareindevoorzieningen en afschrijvingen (12%)
- Loonadministratie-randgevallen (9%)
- Vragen over de rekening-courant van de bestuurder (6%)
- Bankreconciliatie-verschillen (4%)
De resterende 16% was de lange staart. De agent zou die nooit gaan afhandelen. Dat hebben we de partners op dag één verteld. De taak van de agent was om de 84% weg te werken, niet alles. Founders en ops-leads onderschatten hoeveel het succes van de agent afhangt van dit gesprek. Scope hem klein genoeg dat de senior partner de vraagsoorten uit zijn hoofd kan benoemen.
Waarom de M365-tenant-constraint alles vormgaf
Het kantoor opereert onder Belgische en Europese dataregels. Klantgesprekken raken aan uiteindelijk begunstigden, loonstroken, soms ziekteverlofcontext voor doorbetaling. Ze waren al volledig overgestapt op Microsoft 365 met EU-tenant. De harde eis bij de kickoff was dat geen enkel klantbericht de tenant mocht verlaten.
Dat sloot een SaaS-chatwidget uit die transcripten naar een Amerikaanse leverancier stuurt. Het sloot ook het pad van de minste weerstand uit, wat een generieke ChatGPT-achtige integratie zonder audit trail was geweest. We hadden de agent-loop, de gespreksgeschiedenis en de embedding-index allemaal binnen hun Azure-subscription nodig.
De vorm waar we op uitkwamen:
- Teams als klantgerichte interface voor intern personeel. Outlook plus portaal-embed voor klanten.
- Azure OpenAI in de West Europe-regio van het kantoor voor inference. Gesprekslogs in hun eigen Cosmos DB, retentie per klant instelbaar.
- Embeddings geïndexeerd in Azure AI Search, gevoed vanuit een gecureerde kennisbank die de senior partner onderhoudt.
- Visma eAccounting en Yuki benaderd als overwegend-lezen tools. Schrijfacties achter expliciete goedkeuring door een mens.
De Azure OpenAI data, privacy and security docs waren onze referentie voor wat binnen de tenant blijft. De korte versie: prompts en completions worden niet gebruikt voor training, en met abuse monitoring uitgeschakeld (waar het kantoor voor in aanmerking komt) verlaat er niets de Azure-regio van de klant.
Het tool-oppervlak voor Visma en Yuki
Beide boekhoudplatforms hebben een REST API. Die van Visma eAccounting is OAuth-scoped per onderneming. Yuki gebruikt een session token plus een administratie-ID. We hebben elk platform achter een kleine adapter gezet en daarna tools geregistreerd die de agent kon aanroepen. Het punt was de agent onwetend te houden van welk platform een klant gebruikte. Stond de boekhouding van de klant in Yuki, dan riep de agent get_open_invoices aan en routeerde de adapter het verzoek. Hetzelfde voor Visma.
De tool-definities, ingedikt tot drie voorbeelden:
// agent/tools.ts
export const tools = [
{
name: "get_open_invoices",
description: "Return the client's open sales invoices, oldest first.",
input_schema: {
type: "object",
properties: {
client_id: { type: "string" },
as_of: { type: "string", format: "date" },
},
required: ["client_id"],
},
},
{
name: "get_vat_position",
description: "Return VAT collected vs. paid for the current quarter.",
input_schema: {
type: "object",
properties: {
client_id: { type: "string" },
quarter: { type: "string", pattern: "^\\d{4}-Q[1-4]$" },
},
required: ["client_id", "quarter"],
},
},
{
name: "flag_for_partner",
description: "Escalate the thread to the partner-on-call. Use for Wwft, dispute, or any answer the model is less than 80% confident on.",
input_schema: {
type: "object",
properties: {
reason: { type: "string", enum: ["wwft", "dispute", "low_confidence", "out_of_scope"] },
summary: { type: "string", maxLength: 400 },
},
required: ["reason", "summary"],
},
},
];
De adapter achter elke tool is saaie code. Het is ook waar 60% van de bugs in de eerste maand zat. Visma geeft bedragen terug als een string met komma als scheidingsteken wanneer de locale Nederlands is. Yuki geeft het als float terug. Datumformaten verschillen tussen endpoints binnen hetzelfde platform. We hebben een normalisatielaag en tests ervoor geschreven voordat de agent de data ooit zag. Sla je dit over, dan vertelt de agent een klant dat zijn btw-positie er een factor 100 naast zit omdat iemand "1.234,56" als 1,23456 inleest. We hebben die exacte bug gezien.
De Wwft-router
De Belgische antiwitwasregels (de Wwft, omzetting van de EU AML-richtlijnen) verplichten accountants specifieke situaties te melden: ongebruikelijke kasbewegingen, wijzigingen in uiteindelijk begunstigden, bepaalde PEP-interacties, en een handvol typologiepatronen die het kantoor zelf bijhoudt op basis van hun risk appetite. De FOD Financiën AML-overzicht behandelt het kader.
De harde regel van de compliance lead van het kantoor was: kan iets in het gesprek een Wwft-verplichting raken, dan gaat er geen automatisch antwoord uit. De partner-on-call ziet het eerst.
We hebben de router in twee lagen geïmplementeerd. Laag één is een deterministische regex over het inkomende bericht: kasdrempels boven €3.000, vermeldingen van bepaalde jurisdicties, trefwoorden rond uiteindelijk begunstigden in Nederlands en Frans. Goedkoop, snel, geen model in de loop, makkelijk voor de compliance officer om te auditen en aan te passen. Laag twee is het oordeel van de agent zelf: we instrueerden hem om flag_for_partner aan te roepen met reason wwft zodra de deterministische pas iets had gemist maar het model toch risico rook.
# router/wwft_pre_filter.py
import re
CASH_THRESHOLD_EUR = 3000
CASH_PATTERN = re.compile(
r"(?:\b|\u20ac\s?)([0-9]{1,3}(?:[.,][0-9]{3})+|[0-9]{4,})(?:\s?\u20ac| EUR)?",
re.IGNORECASE,
)
KEYWORD_PATTERNS = [
re.compile(r"\bcontant(?:e)?\b", re.IGNORECASE),
re.compile(r"\besp[\u00e8e]ces\b", re.IGNORECASE),
re.compile(r"\buiteindelijk\s+begunstig", re.IGNORECASE),
re.compile(r"\bb[\u00e9e]n[\u00e9e]ficiaire\s+effectif", re.IGNORECASE),
re.compile(r"\bPEP\b"),
]
def wwft_pre_flag(message: str) -> dict | None:
for amount_match in CASH_PATTERN.finditer(message):
raw = amount_match.group(1).replace(".", "").replace(",", "")
try:
if int(raw) >= CASH_THRESHOLD_EUR:
return {"reason": "wwft", "trigger": "cash_threshold", "amount": int(raw)}
except ValueError:
continue
for pattern in KEYWORD_PATTERNS:
if pattern.search(message):
return {"reason": "wwft", "trigger": pattern.pattern}
return None
Geeft het pre-filter een hit terug, dan draait de agent nooit. Het bericht gaat rechtstreeks naar het partner-on-call-kanaal in Teams met een one-line-samenvatting. Het gesprek blijft open. De klant ziet 'je accountant kijkt hier persoonlijk naar', wat ook waar is.
Laat de agent niet alleen beslissen of een Wwft-trigger van toepassing is. Een deterministisch pre-filter is auditbaar. Een model niet. De compliance officer moet de hele regex-lijst in één zitting kunnen lezen.
De agent-loop, met proactiviteit afgeknepen
De zorg die we het vaakst horen van founders die naar agents in productie kijken, is die over een loop die ongemerkt rekeningen opstookt of dingen doet buiten zijn operator om. Een agent-loop zonder expliciete grenzen vindt creatieve manieren om tokens en vertrouwen op te branden.
Deze hebben we hard begrensd. De loop draait maximaal zes tool-calls per gebruikersbericht. Daarboven flagt hij voor een partner met reason low_confidence. Inference-kosten worden bijgehouden per gesprek en per klant, zodat de senior partner kan zien welke klantvragen het duurst zijn om te beantwoorden. De cap is in productie nooit geraakt, wel twee keer in load testing, en zo ontdekten we een bug waarbij Yuki een gepagineerde response teruggaf die de agent tot pagina 99 probeerde door te lopen.
// agent/loop.ts
const MAX_TOOL_CALLS = 6;
async function run(thread: Thread, message: string) {
if (await wwftPreFlag(message)) return escalate(thread, "wwft");
let calls = 0;
let response = await model.respond(thread, message);
while (response.tool_calls.length && calls < MAX_TOOL_CALLS) {
const results = await Promise.all(
response.tool_calls.map((c) => runTool(c, thread.clientId)),
);
calls += results.length;
response = await model.respond(thread, message, results);
}
if (response.tool_calls.length) return escalate(thread, "low_confidence");
if (response.confidence < 0.8) return escalate(thread, "low_confidence");
return reply(thread, response.text);
}
Acht weken later, de cijfers
De agent ging in week zes van de bouw live voor de eerste tien klanten. Volledige uitrol was in week tien. Acht weken na de uitrol rapporteerde het kantoor:
- 980 portaalvragen per week, waarvan 71% beantwoord door de agent zonder dat een partner eraan hoefde te komen.
- 22% geëscaleerd naar associates (vooral lange staart of lage confidence).
- 7% geëscaleerd naar partner-on-call (Wwft, geschillen, complexe voorzieningen).
- Gemiddelde eerste-reactietijd zakte van 9,4 uur naar 11 minuten.
- Twee associates verschoven van portaal-triage naar advieswerk fulltime.
Het cijfer waar we het meest zenuwachtig over waren, was de Wwft-escalatieratio. De senior partner had ons botweg gezegd: beantwoordt de agent één keer in stilte een vraag over een contante storting verkeerd, dan is de bouw mislukt, ongeacht elke andere metric. In productie heeft het pre-filter elk Wwft-relevant bericht gevangen dat een partner achteraf heeft geaudit. De eigen flag_for_partner van de agent met reason wwft is 19 keer afgegaan, waarvan de partner het er 14 keer mee eens was. De vijf valse positieven gingen als negatieve voorbeelden de prompt en de regex-lijst in.
Wat we anders zouden doen
Twee dingen. Eén: we hadden het partner-dashboard moeten bouwen vóór de agent. In de eerste twee productieweken moesten de partners ruwe Teams-threads lezen om te auditen wat de agent had gedaan. Een simpele tabel van 'escalaties vandaag, auto-antwoorden vandaag, gemarkeerd-en-opgelost vandaag' had ze veel tijd bespaard. We leverden hem in week drie. Het had week nul moeten zijn.
Twee: we hebben onderschat hoeveel de toon van de agent uitmaakte. De eerste versie antwoordde als een studieboek. Klanten waren er niet kapot van. De senior partner zat een zaterdag met ons om de system prompt te herschrijven rond hoe hij daadwerkelijk met klanten praat (korte zinnen, geen jargon, één verhelderende vraag stellen voor iets dubbelzinnig te beantwoorden). De acceptatieratio steeg de week daarop elf punten.
Een chat-agent voor een accountantskantoor is twee systemen in één jas: een deterministische compliance-router die nooit een Wwft-bericht bij het model laat komen, en een agent-loop met een confidence cap die alles waar hij niet zeker over is escaleert.
De vijf-minuten-audit die je maandag kunt doen
Heb je een kantoor met een portaalwachtrij en denk je na over een agent, doe dan dit vóór je met iemand praat (met ons of wie dan ook). Trek een week aan portaalgesprekken eruit. Tag ze met de hand in vijf tot acht categorieën. Bereken welk aandeel van het volume de top drie categorieën vertegenwoordigen. Zit dat boven 60%, dan heeft een agent ergens om te starten. Zit het onder 40%, dan is de wachtrij een symptoom van iets anders (waarschijnlijk klant-onboarding) en gaat een agent dat niet oplossen. Toen wij deze chat-agent voor het Mechelse kantoor bouwden, was het antwoord 64%, en dat was het cijfer dat het project liet doorgaan.
Kern
Een chat-agent voor een accountantskantoor is een deterministische Wwft-router en een agent-loop met confidence cap in één jas. Bouw eerst de router.
FAQ
Waarom alles binnen de eigen Microsoft 365-tenant van het kantoor houden?
Belgische en Europese dataregels, plus het feit dat portaalberichten raken aan uiteindelijk begunstigden, salarissen en soms medische context. Een SaaS-chatwidget die transcripten naar een Amerikaanse leverancier stuurt, valt niet te verdedigen tegenover de compliance lead van het kantoor.
Waarom een deterministisch regex-pre-filter voor Wwft in plaats van het model laten beslissen?
Een regex-lijst is in één zitting auditbaar door een compliance officer. Een model niet. Het pre-filter draait vóór het model, dus een gemarkeerd bericht bereikt de inference nooit.
Hoe voorkom je dat de agent eindeloos blijft loopen op tool-calls?
Limiteer tool-calls per gebruikersbericht (wij namen zes) en eis een confidence-drempel op het eindantwoord. Alles dat de cap overschrijdt of onder de drempel valt, gaat naar een mens, niet terug de loop in.
Werkt dit alleen met Visma eAccounting en Yuki?
Nee. De agent roept platform-agnostische tools aan zoals get_open_invoices. Adapters routeren naar het platform dat de klant gebruikt. Exact Online of Twinfield toevoegen is een adapter en een credential, geen herbouw van de agent.