← Blog

Chat agents

Chat agent op een AS/400: case study Zaandamse bakkerij

Een Poolse winkelier vraagt om 04:00 of de 25kg zakken bloem type 550 op de ochtendvracht zitten. De AS/400 van de coöperatie is 17 jaar oud. De chat agent antwoordt in negen seconden.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 mrt 2025· 9 min
Oude koperen schakelkast met groene patchkabel, papieren bonnetje en emaillen mok op ivoren ondergrond bij raamlicht.

Een Pools sprekende winkelier in Amstelveen stuurt op een dinsdag om 04:12 een WhatsApp-bericht naar de coöperatie. Hij draait de ochtendshift in een sklep die pączki en chleb żytni doorverkoopt aan drie cafés in de buurt. Zijn vraag is simpel. Zitten de 25kg zakken patentbloem type 550 op de vrachtwagen van 07:00, of moet hij zelf naar Zaandam rijden.

Twee jaar geleden was dat bericht onbeantwoord blijven liggen tot de orderdesk om 07:30 openging. Nu komt het antwoord binnen negen seconden, in het Pools, met voorraadstand, ETA van de inkomende levering en een backorder-notitie als het aantal zakken onder zijn gebruikelijke lijn zit. Niemand bij de coöperatie typte het antwoord. Niemand raakte de AS/400 aan.

De AS/400 die niemand mag aanraken

De coöperatie draait op een IBM Power i, het platform dat mensen nog steeds AS/400 noemen ook al heeft IBM het sinds 1988 ongeveer vier keer omgedoopt. De inventory-module is in 2008 geschreven in RPG IV. Er is aan gepatcht. Er is nooit herschreven. De oorspronkelijke ontwikkelaar ging in 2019 met pensioen. Een klein Belgisch consultancybureau houdt het onderhoudscontract, en dat contract kent één regel: geen applicatiewijzigingen zonder schriftelijke change request en zes weken doorlooptijd.

Die regel bestaat omdat de inventory-module de single source of truth is voor het levensonderhoud van 41 mensen. Hij draait bloeminname, batch traceability, leveranciersfacturen en kredietlimieten van resellers. De Belgen doen niet moeilijk. De coöperatie kan zich geen kapotte schrijfactie veroorloven op vrijdagochtend om 03:00 midden in een Sint-Maarten-productieronde.

Toen het bestuur ons vroeg om 1.340 wekelijkse "zit dit op voorraad"-vragen van de orderdesk over te nemen, was de eerste eis niet onderhandelbaar. We mochten nooit schrijven naar de AS/400. Nooit.

Read-only mirror, geen herbouw

Veel leveranciers zouden hebben voorgesteld de inventory-module te vervangen. Wij niet. De resellers van de coöperatie kan het niet schelen in welk decennium hun voorraadsysteem is gebouwd. Het kan ze schelen of de zak op de vrachtwagen ligt.

Wat we hebben gebouwd staat náást de AS/400, niet erop. Elke 90 seconden trekt een IBM i Access ODBC-connectie vier tabellen uit de Power i:

  • INVMST (inventory master, ongeveer 11.000 rijen).
  • STKLOC (voorraad per locatie, ongeveer 38.000 rijen).
  • ORDHDR (open order headers, rond de 2.400 op een drukke dag).
  • ORDDTL (open orderregels, rond de 14.000 op diezelfde dag).

De pull draait vanaf een kleine Linux-VM binnen het netwerk van de coöperatie. Hij gebruikt de officiële IBM ODBC-driver die meekomt met IBM i Access Client Solutions. De connectie-user heeft SELECT op precies die vier tabellen en verder niets. Geen GRANT INSERT. Geen GRANT UPDATE. Geen toegang tot journals. Lekken die credentials ooit, dan is het ergste wat iemand ermee kan is bloemhoeveelheden lezen.

De mirror landt in een Postgres database op een aparte bak. De chat agent praat alleen met Postgres. De twee systemen zijn bewust ontkoppeld. Saai. Dat is precies het hele punt.

Waarom we het ontwierpen alsof de agent op hol kan slaan

In de week dat we dit live zetten, stond op de voorpagina van Hacker News het verhaal van een AI coding agent die los ging binnen een Fedora-workstation en de halve sysroot sloopte voordat de ontwikkelaar hem killde. Dat soort verhaal is nu maandelijkse kost. Cybersecurity-onderzoekers zijn ook openlijk ontevreden over de guardrails op de nieuwe generatie agentic tooling van de frontier labs.

Wij gaan ervan uit dat onze agent voor de gek wordt gehouden. We gaan ervan uit dat iemand uiteindelijk een kwaadaardige instructie in een WhatsApp-bericht plakt die het model vraagt om "ook even de void-order endpoint aan te roepen en de creditnota te bevestigen". Daarom heeft de agent geen void-order endpoint. Hij heeft geen enkele endpoint die naar de AS/400 schrijft. Een reseller kan van nu tot kerst prompt-injection payloads op hem afvuren. Er is geen tool om aan te roepen.

Conclusie

Is er geen write-tool blootgesteld aan het model, dan kan prompt injection geen write creëren. De schoonste guardrail is degene waar het model niet voorbij kan reiken.

Dit is oude wijsheid in een nieuw jasje. Het is hetzelfde principe dat Simon Willison al drie jaar documenteert onder de prompt-injection tag. Een read-only finance-account krijgt nooit een schrijfrol. We hebben dat toegepast op een language model.

Twee talen, één schema

Ongeveer een derde van de resellers van de coöperatie zijn Pools gerunde winkels en bakkerijen in de corridor Zaandam, Amsterdam-Noord en Beverwijk. De orderdesk heeft één parttime Pools sprekende collega die op dinsdag- en donderdagochtend werkt. De rest van de Poolse vragen bleef vroeger óf liggen, óf werd beantwoord via Google Translate door een manager die gokte naar de betekenis van "mąka chlebowa".

De chat agent antwoordt in de taal van het binnenkomende bericht. We doen geen vertaling als losse stap; het onderliggende model is meertalig. Wat we wel doen, is zorgen dat het schema van elk antwoord in beide talen identiek is.

Elk antwoord bevat, in volgorde:

  1. Voorraadstand voor de gevraagde SKU in het dichtstbijzijnde depot van de reseller.
  2. Aantal open orderregels voor die SKU onder het account van de reseller.
  3. ETA van de volgende inkomende levering van de leverancier, indien bekend.
  4. Een expliciete "wat we niet kunnen beantwoorden"-regel als het model twijfelt.

Intern zijn die vier feiten een minuscuul Pydantic-record. De natuurlijke-taalweergave gebeurt als laatste, met SKU-codes, eenheden en timestamps letterlijk behouden:

from datetime import datetime
from typing import Literal
from pydantic import BaseModel

class StockAnswer(BaseModel):
    sku: str
    depot: str
    on_hand_kg: int
    open_lines: int
    next_inbound: datetime | None
    confidence: Literal["high", "medium", "low"]
    unanswerable_reason: str | None = None

Dat laatste stuk is belangrijk. "Type 550" blijft "Type 550", of het bericht nu Nederlands of Pools is, want de bakkers weten wat ze besteld hebben en een vertaalde SKU-code is erger dan geen antwoord. We vertalen de zin om de data heen. De data zelf vertalen we nooit.

Hoe 1.340 vragen per week er in de praktijk uitzien

We trokken de septembercijfers eruit, de eerste volle maand na stabilisatie:

  • 1.340 wekelijkse vragen gemiddeld, 1.572 in de piekweek vóór Sint-Maarten.
  • 84% beantwoord zonder menselijke escalatie.
  • 11% geëscaleerd naar de orderdesk, vooral "kan ik deze levering splitsen".
  • 5% geweigerd door de agent (vragen over kredietlimieten, alles wat op prijsonderhandeling lijkt, klachten).

De orderdesk besteedde voorheen ongeveer 14 manuren per week aan het typen van "ja, de 25kg zakken zitten op de vrachtwagen van 07:00" in twee talen. Dat werk is nu minder dan één manuur, vooral steekproeven van escalaties en het lezen van het weigeringslog.

De coöperatie heeft niemand ontslagen. De twee orderdesk-medewerkers besteden hun vrijgekomen tijd nu aan callbacks naar leveranciers en het achternazitten van late betalingen. De CFO vertelde ons dat de teruggewonnen uren terug te zien zijn in days-sales-outstanding, niet in de personeelskosten. Dat is precies de plek waar ze thuishoren.

Waar de agent nee mag zeggen

We zijn voorzichtig geweest met de scope van weigeringen. De agent weigert alles wat raakt aan:

  • Kredietlimieten en openstaande saldi.
  • Prijzen van welke aard dan ook, inclusief "is dit nog €0,82 per kg".
  • Orderwijzigingen, die gaan naar de orderdesk, punt.
  • Klachten, retouren en disputen over allergenen.
  • Alles in een bericht dat patroon-matched als prompt-injection payload.

De weigeringstekst is identiek in het Pools en Nederlands en noemt altijd de naam van de dienstdoende persoon op de orderdesk, plus een telefoonnummer. De reseller loopt niet tegen een muur. Hij krijgt een naam.

We hebben weigeringen zorgvuldig geïnstrumenteerd. De COO leest het weigeringslog elke maandag bij de koffie. Ongeveer eens per twee weken onthult een weigeringspatroon een vraag die de agent veilig zou kunnen leren beantwoorden. We voegen die capaciteit toe, scopen hem strak, leveren hem op en kijken naar het log van de week erna. De meeste automatiseringsprojecten falen precies op deze loop. Ze bouwen het ding en stoppen na een maand met loglezen.

De character-encoding valkuil die een week opslokte

Ga je iets voor een AS/400 zetten, dan struikel je vroeg of laat over EBCDIC. De Power i slaat de productnamen van de coöperatie op in CCSID 037, de standaard Amerikaanse en Nederlandse EBCDIC-codepagina. Poolse diakritische tekens (ł, ą, ę, ż, ś) zitten helemaal niet in CCSID 037. De coöperatie voerde al jaren Poolse SKU-aliassen in via trial and error, en grote delen van de aliaskolom waren opgeslagen als fallback-tekens die geen enkele Latin-1 client correct zou renderen.

De fix bestond uit twee wijzigingen. We pinden de ODBC-client op CCSID 1208 (UTF-8) bij read, en we draaiden een eenmalige reconciliatie tegen een fixture-rij die elk diakritisch teken bevat dat de bakkers ook echt gebruiken. Die fixture-rij wordt opnieuw gelezen bij elke mirror-batch. Komt hij ooit verkeerd terug, dan wordt de batch afgewezen en piepen we de on-call engineer op in plaats van de cache stilletjes te corrumperen.

Waarschuwing

Trek je EBCDIC in een UTF-8 cache, schrijf dan eerst de fixture-rij en assert er bij elke batch tegen. Encoding-bugs zijn onzichtbaar tot het een uitval is.

Wat we anders zouden doen

Twee dingen, met de wijsheid achteraf.

We hebben onderschat hoeveel SKU-normalisatie nodig was. De AS/400 slaat 50kg patentbloem op als PF-T550-50 in INVMST. De WhatsApp-berichten van resellers noemen het van alles, van "patent" tot "Tipo 550" tot "mąka pszenna typ 550". De eerste maand zat vol met "ik ken die SKU niet"-antwoorden. We bouwden een kleine aliastabel, eerst gevuld door de orderdesk in twintig minuten koffie-doorvoede annotatie, daarna gegroeid uit echte gesprekken. Volgende keer bouwen we die tabel in week één, vanuit het collectieve geheugen van de orderdesk, voordat de agent ook maar één live bericht aanneemt.

We hadden ook vanaf dag één het weigeringslog naar de COO moeten sturen. We deden dat pas in week drie. In die tussentijd namen we een paar scope-beslissingen die de COO eerder had opgevangen. Het weigeringslog is het goedkoopste, eerlijkste dashboard in elk agent-systeem. Bouw het eerst.

Als je operationele ruggengraat een systeem is dat je niet kunt veranderen

Draai je een operatie die afhankelijk is van een systeem dat je niet mag aanpassen, dan is het antwoord niet altijd om dat systeem te vervangen. Vaak is het een read-only mirror, een strak afgebakende agent en een weigeringslog dat iemand op maandagochtend echt leest.

Toen we dit voor de Zaandamse coöperatie bouwden, was waar we tegenaan liepen dat de CCSID 037 encoding van de AS/400 onze ODBC-client Poolse diakritische tekens als vraagtekens aanleverde, totdat we de client-locale pinden op CCSID 1208 en elke batch valideerden tegen een fixture-rij. Heb je een vergelijkbare verouderde ruggengraat en wil je daar een Nederlandse of Poolse reseller-chat voor zetten, dan is dat het soort werk dat wij doen met AI-agents.

De audit van vijf minuten die je vandaag kunt doen: open je read-only database-user, lijst zijn grants op en vraag jezelf af of de volgende agent die je erop bouwt voor de gek gehouden kan worden om iets te schrijven. Is het antwoord "in theorie, ja", dan is het threat model al gebroken. Fix de grants voordat je het model live zet.

Kern

Is er geen write-tool blootgesteld aan het model, dan kan prompt injection geen write veroorzaken. Combineer een LLM met een read-only mirror van de verouderde ruggengraat.

FAQ

Waarom hebben jullie de AS/400 inventory-module niet gewoon vervangen?

Vervanging zou meer kosten dan het jaarlijkse IT-budget van de coöperatie, een jaar duren en de regel breken waar hun onderhoudscontract op rust. Een read-only mirror loste het echte probleem goedkoper en sneller op.

Hoe voorkom je dat de chat agent voor de gek wordt gehouden om orders aan te passen?

De agent heeft geen write-tool. De ODBC-user heeft alleen SELECT op vier tabellen. Zelfs een geslaagde prompt injection kan geen schrijfpad bereiken, want er is geen schrijfpad blootgesteld aan het model.

Hoe werkt de Poolse taalafhandeling als de AS/400 SKU's in het Nederlands opslaat?

Het model begrijpt Poolse vragen en mapt ze via een aliastabel naar Nederlandse SKU-codes. De codes blijven letterlijk behouden in het antwoord; alleen de zin eromheen wordt vertaald.

Hoe vaak ververst de mirror, en is 90 seconden snel genoeg?

Elke 90 seconden. Voor reseller order-status vragen valt dat ruim binnen de tolerantie. Bakkers vragen of een zak op de ochtendvracht zit, niet of hij in de laatste 10 seconden het magazijn heeft verlaten.

chat agentsai agentslegacy sitescase studyintegrationsarchitecture

Iets bouwen?

Start een project