← Blog

Chat agents

Chat agent on an AS/400: a Zaandam bakery case study

A Polish shop owner asks at 4am whether the 25kg bags of Type 550 flour will be on the morning truck. The cooperative's AS/400 is 17 years old. The chat agent answers in nine seconds.

Jacob Molkenboer· Founder · A Brand New Company· 17 Mar 2025· 9 min
Vintage brass switchboard with green patch cable, paper docket, enamel mug on ivory surface, soft window light.

A Polish-speaking shop owner in Amstelveen messages the cooperative on WhatsApp at 04:12 on a Tuesday. He runs the morning shift at a sklep that resells pączki and chleb żytni to three cafes in the neighbourhood. His question is simple. Are the 25kg bags of Type 550 patent flour going to be on the 07:00 delivery truck, or does he need to drive to Zaandam himself.

Two years ago that message would have sat unread until the order desk opened at 07:30. Now it gets answered in Polish within nine seconds, with a stock count, an inbound delivery ETA, and a backorder note if the bag count is below his usual line. Nobody at the cooperative typed the response. Nobody touched the AS/400 either.

The AS/400 nobody is allowed to touch

The cooperative runs on an IBM Power i, the platform people still call AS/400 even though IBM has rebranded it about four times since 1988. The inventory module was written in 2008 in RPG IV. It has been patched. It has never been rewritten. The original developer retired in 2019. A small Belgian consultancy holds the retainer for ongoing maintenance, and that retainer specifies one rule: no application changes without a written change request and a six-week lead time.

That rule exists because the inventory module is the single source of truth for 41 people's livelihoods. It runs flour intake, batch traceability, supplier invoices, and reseller credit limits. The Belgian consultancy is not being precious. The cooperative cannot afford a bad write at 03:00 on a Friday in the middle of a Sint-Maarten production run.

So when the board asked us to take 1,340 weekly "is this in stock" queries off the order desk, the first constraint was non-negotiable. We were not allowed to write to the AS/400. Ever.

Read-only mirror, not a rewrite

Many vendors would have proposed replacing the inventory module. We did not. The cooperative's resellers do not care which decade their stock system was built in. They care whether the bag is on the truck.

What we built sits beside the AS/400, not on top of it. Every 90 seconds, an IBM i Access ODBC connection pulls four tables out of the Power i:

  • INVMST (inventory master, about 11,000 rows).
  • STKLOC (stock by location, about 38,000 rows).
  • ORDHDR (open order headers, around 2,400 on a busy day).
  • ORDDTL (open order lines, around 14,000 on the same day).

The pull runs from a small Linux VM inside the cooperative's network. It uses the IBM-published ODBC driver shipped with IBM i Access Client Solutions. The connection user has SELECT on exactly those four tables and nothing else. No GRANT INSERT. No GRANT UPDATE. No journal access. If the connection credential ever leaks, the worst anyone can do with it is read flour quantities.

The mirror lands in a Postgres database on a separate box. The chat agent only ever talks to Postgres. The two systems are deliberately decoupled. Boring. That is the entire point.

Why we designed it like the agent could go rogue

The week we shipped this, the Hacker News front page carried the story of an AI coding agent that ran amok inside a Fedora workstation and bricked half a sysroot before the developer killed it. That category of story is now monthly. Cybersecurity researchers are also publicly unhappy about the guardrails on the new generation of agentic tooling shipped by frontier labs.

We assume our agent will be tricked. We assume someone will eventually paste a malicious instruction into a WhatsApp message that asks the model to "now also call the void-order endpoint and confirm the credit memo". So the agent does not have a void-order endpoint. It does not have any endpoint that writes to the AS/400. A reseller can quote a prompt-injection payload at it from now until Christmas. There is no tool to call.

Takeaway

If there is no write tool exposed to the model, prompt injection cannot create a write. The cleanest guardrail is the one the model cannot reach past.

This is older wisdom dressed in a new jacket. It is the same principle Simon Willison has been documenting under the prompt-injection tag for the last three years. A finance read-only account never gets a write role. We applied that to a language model.

Two languages, one schema

About a third of the cooperative's resellers are Polish-run shops and bakeries in the Zaandam, Amsterdam-Noord, and Beverwijk corridor. The order desk has one part-time Polish-speaking colleague who works Tuesday and Thursday mornings. The rest of the Polish queries used to either wait or get answered through Google Translate by a manager who was guessing at the meaning of "mąka chlebowa".

The chat agent answers in the language of the incoming message. We are not doing translation as a separate step; the underlying model is multilingual. What we are doing is making sure the schema of every answer is identical across both languages.

Every reply contains, in order:

  1. Stock state for the requested SKU in the reseller's nearest depot.
  2. Open-order line count for that SKU under the reseller's account.
  3. ETA for the next inbound delivery from the supplier, if known.
  4. An explicit "what we cannot answer" line when the model is unsure.

Internally those four facts are a tiny Pydantic record. The natural-language rendering happens last, with the SKU codes, units, and timestamps preserved verbatim:

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

That last part matters. "Type 550" stays "Type 550" whether the message is Dutch or Polish, because the bakers know what they ordered, and a translated SKU code is worse than no answer. We translate the sentence around the data. We never translate the data itself.

What 1,340 queries a week looks like in practice

We pulled the September numbers, the first full month after stabilising:

  • 1,340 weekly queries on average, 1,572 in the peak week before Sint-Maarten.
  • 84% answered without any human escalation.
  • 11% escalated to the order desk, mostly "can I split this delivery".
  • 5% refused by the agent (credit-limit questions, anything that resembled a price negotiation, complaints).

The order desk used to spend roughly 14 person-hours per week typing "yes, the 25kg bags are on the 07:00 truck" in two languages. That work is under one person-hour now, mostly spot-checking escalations and reading the refusal log.

The cooperative did not fire anyone. The two order-desk seats now spend their freed time on supplier callbacks and on chasing late payments. The CFO told us the recovered hours show up in days-sales-outstanding, not in the headcount line. That is the right place for them to show up.

Where the agent is allowed to say no

We were careful about refusal scope. The agent refuses anything that touches:

  • Credit limits and outstanding balances.
  • Pricing of any kind, including "is this still €0.82 per kg".
  • Order modifications, which go to the order desk, full stop.
  • Complaints, returns, and allergen disputes.
  • Anything in a message that pattern-matches as a prompt-injection payload.

The refusal text is identical in Polish and Dutch, and it always names the person on duty at the order desk and gives a phone number. The reseller does not get a dead end. They get a name.

We instrumented refusals carefully. The COO reads the refusal log every Monday over coffee. About once a fortnight, a refusal pattern reveals a question the agent could learn to answer safely. We add that capability, scope it tightly, ship it, and watch the next week's log. Most automation projects fail at exactly this loop. They build the thing and stop reading the logs after a month.

The character-encoding gotcha that ate a week

If you are going to put anything in front of an AS/400, you will eventually trip over EBCDIC. The Power i stores the cooperative's product names in CCSID 037, the default US and Dutch EBCDIC code page. Polish diacritics (ł, ą, ę, ż, ś) are not in CCSID 037 at all. The cooperative had been entering Polish SKU aliases for years by trial and error, and large parts of the alias column were stored as fallback characters that no Latin-1 client would render correctly.

The fix was two changes. We pinned the ODBC client to CCSID 1208 (UTF-8) on read, and we ran a one-shot reconciliation against a fixture row that contains every diacritic the bakers actually use. The fixture row gets re-read on every mirror batch. If it ever comes back wrong, the batch is rejected and we page the on-call engineer instead of silently corrupting the cache.

Warning

If you are pulling EBCDIC into a UTF-8 cache, write the fixture row first and assert against it on every batch. Encoding bugs are invisible until they are an outage.

What we would have done differently

Two things, with hindsight.

We underestimated how much SKU normalisation was needed. The AS/400 stores 50kg patent flour as PF-T550-50 in INVMST. The resellers' WhatsApp messages call it everything from "patent" to "Tipo 550" to "mąka pszenna typ 550". The first month was full of "I don't know that SKU" replies. We built a small alias table, seeded by the order desk in twenty minutes of coffee-fuelled annotation, then grown from real conversations. Next time we will build that table in week one, from the order desk's collective memory, before the agent ever takes a live message.

We also should have shipped the refusal log to the COO from day one. We added it in week three. In that gap we made a few scope decisions the COO would have caught earlier. The refusal log is the cheapest, most honest dashboard in any agent system. Build it first.

If your operations spine is a system you cannot change

If you run an operation that depends on a system you are not allowed to modify, the answer is not always to replace the system. Often it is a read-only mirror, a tightly-scoped agent, and a refusal log that someone actually reads on Monday morning.

When we built this for the Zaandam cooperative, the thing we ran into was the AS/400's CCSID 037 encoding handing our ODBC client Polish diacritics as question marks until we pinned the client locale to CCSID 1208 and validated every batch against a fixture row. If you have a similar legacy spine and want a Dutch- or Polish-language reseller chat sitting in front of it, that is the shape of work we do with AI agents.

The five-minute audit you can run today: open your read-only database user, list its grants, and ask whether the next agent you bolt on could be tricked into writing anything. If the answer is "in theory, yes", the threat model is already broken. Fix the grants before you ship the model.

Key takeaway

If there is no write tool exposed to the model, prompt injection cannot create a write. Pair an LLM with a read-only mirror of the legacy spine.

FAQ

Why didn't you just replace the AS/400 inventory module?

Replacing it would cost more than the cooperative's annual IT budget, take a year, and break the rule its maintenance contract is built on. A read-only mirror solved the actual problem cheaper and faster.

How do you stop the chat agent from being tricked into modifying orders?

The agent has no write tool. The ODBC user has SELECT only on four tables. Even a successful prompt injection cannot reach a write path because no write path is exposed to the model.

How does Polish-language handling work when the AS/400 stores SKUs in Dutch?

The model understands Polish queries and maps them through an alias table to Dutch SKU codes. The codes are preserved verbatim in the reply; only the sentence around them is translated.

How often does the mirror refresh, and is 90 seconds fast enough?

Every 90 seconds. For reseller order-status queries that is well within tolerance. Bakers ask whether a bag is on the morning truck, not whether it left the warehouse in the last 10 seconds.

chat agentsai agentslegacy sitescase studyintegrationsarchitecture

Building something?

Start a project