Process automation
Playbook douane-agent: 3.180 aangiften per week, drie systemen
Een Haagse expediteur met 26 mensen, 3.180 douaneaangiften per week, drie douanesystemen die niet overeenkomen, en één wachtrij voor alles boven €25.000 invoerrechten.

Maandag 06:40 in Den Haag. De weekenddienst heeft tussen vrijdagmiddag en zondagavond 612 douaneaangiften ingediend in AGS. Vierentachtig staan in een "controle pending"-status, omdat de CSV uit Cargonaut niet klopt met de AGS-bevestiging. Eenenveertig daarvan dragen elk meer dan €25.000 aan invoerrechten, en de senior douane-expediteur zit pas om 08:30 achter zijn bureau. Tot hij tekent, vertrekt er niets.
Dit is de operator waarvoor we het hebben gebouwd: zesentwintig mensen, één kantoor in Den Haag, ongeveer 3.180 douaneaangiften per week verdeeld over drie systemen die nooit zijn ontworpen om met elkaar te praten. Wat volgt is het playbook dat we op dag één hadden willen hebben.
De vorm van het werk
Elke zending die uit het warehouse rolt, raakt drie systemen voordat de goederen mogen vertrekken. AGS is het Nederlandse invoeraangiftesysteem van de Belastingdienst. Het wordt uitgefaseerd ten gunste van DMS, maar in 2026 draait het grootste deel van de invoer er nog op. NCTS is het EU-transitsysteem, met MRN als canonieke join-key. En dan is er nog het Cargonaut-portaal op Schiphol, inmiddels opgegaan in Portbase, maar nog steeds met een 17 jaar oude web frontend voor pre-notificaties van luchtvracht.
De interessante fouten zitten in de naden. AGS bevestigt een aangifte. NCTS opent een transit. Cargonaut meldt dat de lading is aangekomen. En dan klopt er iets niet: het gewicht in het Cargonaut-record is 14 kg lichter dan de AGS-goederencoderegel, of de MRN op het NCTS-document wijst naar een aangifte die is geannuleerd en onder een ander nummer opnieuw is ingediend. Het is de taak van de operator om dat te vangen voordat de Douane het doet.
Voordat de agent bestond, gebeurde dat vangen in een gedeelde Excel die één senior expediteur handmatig bijhield. Dat kostte elke ochtend ongeveer vier uur. Op maandag waren het er acht.
Tien weken lezen voordat je schrijft
De ene beslissing die ons heeft behoed voor een rampzalige live-datum, was om de eerste tien weken het AGS submit-endpoint niet aan te roepen.
De agent draaide read-only tegen alle drie de systemen. Hij trok de AGS XML-feed binnen, polde NCTS via het officiële kanaal en scrapte Cargonaut met een headless browser. Alles belandde in een Postgres-schema dat we behandelden als append-only. Geen updates, geen deletes. Elke ingest produceerde een nieuwe rij met als sleutel (source_system, source_id, observed_at).
De read-only fase leverde drie dingen op. Ten eerste een echte diff tussen wat de operator dácht dat er in elk systeem stond en wat er werkelijk in stond. In drie van de eerste vier weken kwamen aangiften boven water waarvan het team niet wist dat de Douane ze al had geannuleerd. Ten tweede een basis-foutpercentage: van de 12.400 aangiften die de agent in stilte volgde, had 0,7% een gewichtsverschil tussen Cargonaut en AGS dat groot genoeg was om relevant te zijn. Dat getal definieerde de reconciliatie-queue. Ten derde het vertrouwen om de write path live te zetten. Tegen week tien kwamen de voorgestelde acties van de agent op 97,2% van de retrospectief beoordeelde cases overeen met wat de senior expediteur daadwerkelijk had gedaan. Het verschil van 2,8% zat vrijwel volledig in edge cases rond gecharterde luchtvracht.
Als je agent uiteindelijk douaneaangiften gaat indienen, is het nuttigste wat hij in de eerste maand kan doen: in stilte meekijken en op papier oneens zijn met mensen.
De vier-ogen-wachtrij van €25.000
De grens van €25.000 aan invoerrechten is geen wettelijke lijn. Het is de lijn waarboven een fout de expediteur zo hard raakt dat de senior expediteur eiste dat er een tweede paar ogen meekeek voordat er iets werd ingediend. Dat is een business rule, en de agent dwingt die af voordat hij het AGS submit-endpoint überhaupt aanraakt.
De gate zit in de applicatiecode, niet in een policy-document. Zo ziet de check er in versimpelde vorm uit:
async function gateForSubmit(aangifte: Aangifte): Promise<GateResult> {
const duties = computeInvoerrechten(aangifte);
if (duties.cents >= 2_500_000) {
return {
action: "queue_for_review",
queue: "four_eyes_high_value",
reason: `invoerrechten ${formatEUR(duties)} exceeds €25,000 threshold`,
requiredApprovers: 2,
};
}
const anomalies = await detectAnomalies(aangifte);
if (anomalies.length > 0) {
return {
action: "queue_for_review",
queue: "anomaly_review",
reason: anomalies.map(a => a.label).join("; "),
requiredApprovers: 1,
};
}
return { action: "submit_directly" };
}
Twee dingen zijn hier belangrijk. Ten eerste: de drempel is één constante in één bestand. De compliance officer kan hem zonder deploy aanpassen zodra we hem in de admin-UI ontsluiten, maar hij staat nooit in een LLM-prompt. Ten tweede: de wachtrij is een echt database-object met benoemde fiatteurs en een state machine, geen Slack-kanaal.
Laat het LLM nooit beslissen of een aangifte een geldgrens overschrijdt. Bereken het bedrag in code, vergelijk met een operand, en vertak op de uitkomst. Het model mag de case samenvatten voor de reviewer; de gate is een functie.
Idempotentie op de AGS-submit
Het AGS submit-endpoint is niet idempotent op een manier die je van een moderne API zou herkennen. POST je dezelfde XML twee keer met twee verschillende correlation IDs, dan krijg je twee aangiften, twee MRN's en een telefoontje van de Douane.
We hebben dit opgelost met één unieke Postgres-index en een bewust saai outbox-pattern.
create table ags_submissions (
id bigserial primary key,
intent_hash bytea not null,
aangifte_id bigint not null references aangiften(id),
status text not null check (status in (
'pending','sent','acknowledged','rejected'
)),
ags_mrn text,
sent_at timestamptz,
ack_at timestamptz,
payload_xml text not null,
created_at timestamptz not null default now()
);
create unique index ags_submissions_intent_uniq
on ags_submissions (intent_hash);
De intent_hash is een SHA-256 over de gecanoniseerde XML-payload plus de aangifteversie. Probeert dezelfde payload twee keer de outbox in te gaan, dan weigert Postgres. De submitter-worker leest alleen pending-rijen, verstuurt ze en werkt de status bij. Crasht het systeem tijdens een submit, dan blijft er een sent-rij zonder bevestiging staan; de reconciler vraagt AGS op correlation ID het MRN op en vult of ags_mrn aan, of zet de status op rejected.
Dit is oninteressante infrastructuur. Het is ook het verschil tussen 's nachts slapen en de douane-expediteur die om 23:00 belt omdat er twee MRN's op één container staan.
NCTS, MRN en de join die iedereen verkeerd doet
NCTS geeft je een MRN. AGS geeft je een ander MRN. Cargonaut geeft je voor luchtvracht vaak geen van beide en identificeert de zending op AWB. De verleiding is om een fuzzy matcher te schrijven. Doe het niet.
Wat wel werkte: een strikte join-tabel die alleen gevuld werd door operaties die de agent zelf uitvoerde, plus een expliciete unknown_link-rij voor alles wat hij wel waarnam maar niet zelf initieerde. De agent verzint nooit een link tussen een AGS-aangifte en een NCTS-transit. Hij heeft of een deterministische koppeling uit een workflow die hij zelf heeft gedraaid, of hij vraagt de operator om bevestiging en schrijft het antwoord terug.
Fuzzy matching op douanedocumenten leest prachtig in een design-doc en produceert rechtszaken in productie.
Het 17 jaar oude portaal scrapen
De Cargonaut-frontend is ouder dan de iPhone. Hij gebruikt frames, server-side session cookies en een CSRF-token in een verborgen formulierveld. Voor de data die we nodig hadden, is geen officiële API. Dus stuurden we hem aan met een headless browser, langzaam en beleefd.
De regels die we met de IT-contactpersoon van de expediteur hebben afgesproken:
- Eén scraper-sessie per kantooruur, nooit parallel.
- De sessie gebruikt een eigen service-account, niet het account van een operator.
- Elke request wordt gelogd met een request ID dat in de audit trail blijft staan.
- Geeft het portaal een 5xx of een loginpagina terug, dan stopt de scraper en alarmeert hij de operator in plaats van het opnieuw te proberen.
De scraper is het enige onderdeel van het systeem dat ooit hapert. We behandelen hem als de onbetrouwbare verteller die hij is en sluiten zijn output altijd aan tegen AGS, nooit andersom.
Append-only als audit log
Douane-administratie moet in Nederland zeven jaar bewaard worden. En de gewoonte is dat erom gevraagd wordt, in de rechtszaal, in het slechtst denkbare kwartaal. De database van de agent kent geen UPDATE- of DELETE-statements in applicatiecode. Statuswijzigingen zijn nieuwe rijen.
Dat klinkt duur tot je het uitrekent. Bij 3.180 aangiften per week, met ongeveer twaalf statusovergangen per stuk, schrijft de agent zo'n 38.000 auditrijen per week. Dat zijn twee miljoen rijen per jaar. Een bescheiden Postgres-instance merkt daar niets van.
Er is een verwant punt dat de laatste tijd vaker opduikt: in Postgres is de enige delete die schaalt zonder migratieplan DROP TABLE. Moet je ooit een groot stuk data verwijderen, dan is partitioneren op maand of jaar en hele partities droppen het enige pad dat de tabel niet wekenlang opzwelt. Bouw die partitionering vanaf dag één in, ook als je denkt dat je hem nooit nodig zult hebben. Wij deden dat niet, en de opruimactie na onze eerste retentie-review duurde langer dan nodig was.
Waar de agent stopt en de mens begint
De agent ondertekent geen aangiften. Hij velt geen juridische oordelen. Hij stelt de case samen, berekent de invoerrechten, kiest de wachtrij en schrijft een samenvatting van één alinea over waarom deze aangifte in deze wachtrij staat. De senior expediteur leest de samenvatting, opent de brondocumenten in één klik, en keurt goed of af.
Wat er op kantoor veranderde: de maandagochtend-reconciliatie ging van acht uur van één senior naar minder dan dertig minuten van twee junioren die de high-value wachtrij wegwerken. De categorie "aangiften waarvan het team niet wist dat ze waren geannuleerd" verdween, omdat de agent annuleringen binnen vijftien minuten na publicatie door de Douane signaleert. De senior expediteur is niet langer eigenaar van de Excel. Hij is eigenaar van het beleid, de drempels en de uitzonderingen. De Excel is weg.
Wat we anders zouden doen
Drie dingen, met negen maanden productie achter de rug.
De migratie naar DMS komt eraan en we hebben onderschat hoeveel AGS XML-koppeling er in de agent-code zit. Als we vandaag zouden beginnen, zetten we vanaf dag één een dunne interne API voor "het aangiftesysteem", met AGS als eerste adapter en DMS als tweede. Vooraf goedkoop om toe te voegen, achteraf pijnlijk om in te bouwen.
De vier-ogen-wachtrij had vanaf week één met een service-level objective live moeten gaan. De expediteur gaf meer om de latency op maandagochtend dan om totale throughput, en wij hebben de eerste zes weken op het verkeerde geoptimaliseerd. Definieer de SLO samen met de operator voordat je het dashboard bouwt.
De scraper had het eerste onderdeel moeten zijn dat we in een eigen proces met een eigen restart-policy isoleerden. Valt hij om, dan wil je dat de rest van de agent gewoon doorwerkt op AGS- en NCTS-data, zonder te doen alsof Cargonaut leeg is. Dat leerden we om 02:00 op een dinsdag.
Het kleinste dat je vandaag kunt doen
Draai je een proces waarin twee systemen het eens horen te zijn en waar een mens ze elke ochtend in een spreadsheet bij elkaar legt, besteed dan één middag aan het schrijven van de read-only diff. Stel nog geen acties voor. Tel een week lang, elke dag, hoe vaak de twee systemen het oneens zijn en hoe groot het verschil is. Dat getal is in één keer je business case, je error budget en je drempel voor de reviewer-queue.
Toen we de reconciliatie-agent voor de Haagse expediteur bouwden, was het langste werk niet de AGS-integratie of de Cargonaut-scraper. Het was het verdienen van het recht om het submit-endpoint aan te roepen, door eerst tien weken lang in stilte gelijk te hebben. Dat is de vorm van elke process-automation agent die we sindsdien hebben opgeleverd.
Kern
Als je agent uiteindelijk douaneaangiften gaat indienen, is het nuttigste wat hij in de eerste maand kan doen: in stilte meekijken en op papier oneens zijn met mensen.
FAQ
Waarom bouwen op AGS als de Belastingdienst overgaat naar DMS?
In 2026 draait het grootste deel van de invoerstromen nog op AGS in productie. We hebben AGS eerst gebouwd omdat daar het volume zit. DMS gaat achter dezelfde interne aangifte-API zodra het productie-endpoint algemeen beschikbaar is.
Hoe lang duurde de hele bouw?
Ongeveer negen maanden. Tien weken read-only observatie, zes weken bouwen aan de vier-ogen-wachtrij en de outbox, daarna een gefaseerde uitrol waarbij de agent één documenttype tegelijk overnam, beginnend met de lanes met de laagste waarde.
Wat gebeurt er als de agent en de operator het oneens zijn?
De beslissing van de operator wint, altijd, en het meningsverschil wordt gelogd. We bekijken clusters van meningsverschillen maandelijks. Patronen worden of nieuwe regels in de policy-code van de agent, of nieuwe notities voor de operators.
Bepaalt het LLM ooit de douanecode of berekent het de invoerrechten?
Nee. Douanecodes en berekeningen van invoerrechten zijn deterministisch tegen het Tarief en draaien in code. Het LLM vat de case samen voor de reviewer en schrijft conceptnotities. Getallen en gates komen nooit uit het model.