← Blog

Chat agents

Chat agent op CodeIgniter 2: Zaandamse bakkerijgroothandel

Een bakkerijgroothandel met 21 mensen in Zaandam laat 1.840 ordervragen per week lopen via een chat agent die een 12 jaar oud CodeIgniter-systeem leest zonder het te herschrijven.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 jun 2026· 9 min
Houten schakelbord met messing jacks, stoffen kabel in buispostkoker, papieren formulier, groene sticky op ivoor bureau.

Maandagochtend op een laadperron in Zaandam

Half elf. De orderbalie van een familiebedrijf in bakkerijgrondstoffen in Zaandam heeft 47 ongelezen chatberichten, 18 ongelezen e-mails en drie telefoonlijnen die knipperen. Een bakker in Purmerend wil weten of de zakken patentbloem van 25 kg de woensdagvracht halen. Een patisserie in Hoorn vraagt 12% korting op de chocoladecouverture als ze de order van 60 naar 100 kg ophogen. Een leerling in Zaanstad heeft een foto van een label gestuurd en wil het artikelnummer weten.

Het bedrijf heeft 21 mensen. Twee daarvan werken aan de orderbalie. De artikelencatalogus staat al twaalf jaar in dezelfde database, in een CodeIgniter 2.2 voorraadsysteem dat de neef van de oprichter in 2014 schreef en waar sindsdien niemand aan heeft gezeten. Vandaag verwerkt de chatwidget op de website 1.840 van dit soort vragen per week, elk binnen drie seconden, zonder ook maar één regel naar de legacy database te schrijven, en zonder ooit op eigen houtje een korting boven de 18% af te geven.

Dit is het verhaal van hoe we dat hebben opgezet, wat de agent mag lezen maar nooit mag schrijven, en waar we de grens hebben getrokken voor de accountmanagers.

Het twaalf jaar oude voorraadsysteem

De stack waar de agent bovenop zit: PHP 5.6, CodeIgniter 2.2, MySQL 5.7, één grote artikelen-tabel met 14.200 SKU's, een aparte prijsstaffel-tabel met klantspecifieke staffels en een bestelling-tabel die het bedrijf in elf jaar niet heeft opgeschoond.

PHP 5.6 ging in januari 2019 end of life. De PHP EOL-tabel laat hem nog steeds rustig in de unsupported-kolom staan. De legacy controllers gebruiken afgekeurde mysql_*-helpers, een eigen session library en drie verschillende manieren om strings te escapen, afhankelijk van welke developer welk bestand in welk jaar schreef.

We hadden het kunnen herschrijven. De eigenaar wilde dat niet. Het systeem werkt, de accountmanagers kennen het uit hun hoofd en het maandrapport klopt tot op de cent. De opdracht was om er een chat agent bovenop te zetten zonder ook maar één legacy controller aan te raken.

Alleen leespaden, nooit schrijven

Regel één, vastgelegd in week één: de chat agent doet geen INSERTs of UPDATEs op de legacy MySQL. Elke schrijfactie loopt via de SAP Business One Service Layer die het bedrijf al gebruikt voor de ERP-boekhouding. Vanuit de agent gezien is het voorraadsysteem read-only. Die ene regel hield het legacy team de hele bouwperiode van onze rug af.

We zetten een kleine Node-service voor een read-replica van de MySQL-instance en boden een getypte API aan die de agent als tools kan aanroepen. Drie endpoints, geen writes, geïndexeerde reads:

// tools/artikel.ts
export const lookupArtikel = {
  name: "lookup_artikel",
  description: "Return SKU, name, unit, stock and base price for an artikelcode.",
  input_schema: {
    type: "object",
    properties: { code: { type: "string", pattern: "^[A-Z0-9-]{4,12}$" } },
    required: ["code"],
  },
  async handler({ code }: { code: string }) {
    const row = await db.one(
      `SELECT a.code, a.naam, a.eenheid, v.voorraad, a.basisprijs
         FROM artikelen a
         JOIN voorraad v ON v.artikel_id = a.id
        WHERE a.code = ? AND a.actief = 1
        LIMIT 1`,
      [code]
    );
    return row ?? { error: "not_found" };
  },
};

Het schema is het contract. De agent kan niet vragen om "alle bloem-SKU's die met PA beginnen", want die tool bestaat niet. Hij kan alleen een code opzoeken die hij al uit de woorden van de klant heeft afgeleid. Die beperking dwingt een schone retrieval-stap eerder in de pipeline af en voorkomt dat MySQL gevraagd wordt om de halve wereld te leveren.

Artikel-lookup onder de 480 ms houden

De orderbalie maakt het niet uit of de agent intelligent is. Ze willen dat hij snel is. De interne SLA die we afspraken: een artikel-lookup, end to end inclusief de tool call round trip, is binnen 480 ms klaar op het 95e percentiel. Trager dan dat en de chat begint aan te voelen als de oude PHP-backend, en dan verliest de agent het vertrouwen dat hij net heeft opgebouwd.

Drie dingen maken dat getal haalbaar.

Eén: een in-memory cache van de volledige artikelen-tabel in de agent-service. 14.200 rijen is niets. We hydrateren bij boot en ververen elke vijf minuten met één SELECT op de read replica. Lookups op code zijn binnen microseconden klaar, nog voordat de agent MySQL überhaupt aanraakt.

Twee: een MySQL ngram fulltext-index op de naam-kolom voor fuzzy matches. "patent bloem 25kg" van een klant wordt een sub-milliseconde zoekactie op de gecachede catalogus, waarbij de database alleen wordt geraadpleegd om te bevestigen dat de kandidaat nog actief is.

Drie: de prijsstaffel-join is gedenormaliseerd naar een JSON-document per klant, dat 's nachts wordt ververst. De staffel van de klant wordt één keer bepaald aan het begin van het gesprek en voor de hele session vastgehouden. De agent vraagt hem niet bij elke beurt opnieuw op.

Kernpunt

Als je agent snel moet zijn, laat het model dan niet beslissen wat er in de cache komt. Beslis het zelf, hydrateer bij boot en bied alleen de lookup aan die de agent mag doen.

De 18%-regel en de accountmanager-queue

De marges van de groothandel op bulkbloem zijn dun. De eigenaar hanteert al sinds 1998 een regel: geen korting boven de 18% gaat de deur uit zonder dat een mens er een handtekening onder zet. De accountmanagers weten welke klanten 20% verdienen op couverture en welke gewoon een nee krijgen. Die tacit knowledge zit niet in de database. Die zit in hun hoofd.

De agent biedt dus nooit op eigen houtje een korting boven de 18% aan. De regel zit in de tool-definitie voor de kortingsberekening, niet in een system prompt:

// tools/kortingsafspraak.ts
export const proposeKorting = {
  name: "propose_korting",
  description:
    "Propose a discount percentage for a line. Above 18% parks the order in the accountmanager queue.",
  input_schema: {
    type: "object",
    properties: {
      artikelcode: { type: "string" },
      qty: { type: "number" },
      voorgesteld_pct: { type: "number", minimum: 0, maximum: 35 },
      klantcode: { type: "string" },
    },
    required: ["artikelcode", "qty", "voorgesteld_pct", "klantcode"],
  },
  async handler(input) {
    if (input.voorgesteld_pct > 18) {
      await queue.push("accountmanager", {
        ...input,
        reden: "korting_boven_18",
      });
      return { status: "geparkeerd", queue: "accountmanager" };
    }
    return { status: "ok", korting_pct: input.voorgesteld_pct };
  },
};

De agent kan de regel letterlijk niet omzeilen. Als het model besluit dat de klant écht 22% korting heeft verdiend, geeft de tool een geparkeerd-status terug en pivot het gesprek naar "ik leg dit even voor aan je accountmanager, je hoort binnen een werkdag van ons." De accountmanager ziet een schone queue met het artikel, de qty, de klanthistorie en de redenering van het model, en keurt goed of af vanuit een eenvoudig Slack-achtig dashboard.

In de eerste acht weken belandde 11,4% van de gesprekken in de queue. De eigenaar had op 30% gerekend. Niemand op kantoor was meer verrast dan hij.

De overdracht naar SAP Business One

Als een gesprek wel netjes wordt afgesloten, vuurt de agent een sales order af op de SAP Business One Service Layer die het bedrijf al gebruikt. De SAP Business One-documentatie beschrijft het Orders endpoint dat we aanroepen; het accepteert een nette JSON-body en geeft bij succes de DocEntry terug.

// integrations/sapb1.ts
async function createSalesOrder(order: ParkedOrder) {
  const res = await fetch(`${process.env.B1_BASE}/Orders`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Cookie: await getSessionCookie(),
    },
    body: JSON.stringify({
      CardCode: order.klantcode,
      DocDate: order.datum,
      DocumentLines: order.regels.map((r) => ({
        ItemCode: r.artikelcode,
        Quantity: r.qty,
        DiscountPercent: r.korting_pct,
      })),
    }),
  });
  if (!res.ok) throw new SapError(await res.text(), res.status);
  return (await res.json()).DocEntry as number;
}

De session cookie van de Service Layer verloopt elke 30 minuten. We ververen hem lazy bij een 401, wat netjes klinkt maar ons een week kostte toen de eerste refresh onder load met zichzelf in de race ging en twee dubbele orders binnen dezelfde minuut postte. De fix was een mutex rond de refresh en een idempotency key op de order-metadata. Standaard distributed-systems werk, maar het verdient een vermelding.

Waarschuwing

SAP B1 Service Layer-sessies verlopen stilletjes. Als je agent-service uit meerdere processen bestaat, zet dan een mutex rond de refresh, anders post je dubbele orders zodra een session voor het eerst onder load verloopt.

Wat de klant ziet

De chatwidget op de website van de groothandel ziet er niet uit als een bot. Er staat "Stel een vraag aan de orderbalie" en er hangt een klein avatar van een echte accountmanager bij. De klant typt in het Nederlands, de agent antwoordt in het Nederlands, en op geen enkel moment kondigt hij aan dat hij een language model is.

Een typisch gesprek: "Hoi, kunnen jullie woensdag 25 zakken patentbloem leveren in Purmerend?" De agent resolvet "patentbloem" naar artikelcode PAT-25KG, leest de voorraad uit, controleert de vaste woensdagroute naar Purmerend, bevestigt een slot en vraagt de klant om akkoord. Drie beurten, acht seconden van begin tot eind, geen mens in de loop.

Als de klant op prijs doordrukt, zoekt de agent de prijsstaffel voor die klantcode op en biedt de korting waar de database zegt dat ze recht op hebben, en stopt daar. Vraagt de klant om meer, dan geeft de tool een geparkeerd-status terug en pivot het gesprek naar de accountmanager.

Waar HN over discussieerde toen we dit bouwden

De week dat we de cachelaag live zetten, vroeg een thread op Hacker News of iemand Claude of GPT had vervangen door een lokaal model voor dagelijks coderen. Hij stond twee dagen bovenaan de voorpagina. Het eerlijke antwoord voor een agent als deze is: gedeeltelijk.

De intent-matching stap die klantformuleringen als "twee zakken patent" naar een artikelcode mapt, draait op een klein lokaal embedding-model op één Hetzner-bak. Goedkoop, snel en het verlaat nooit het netwerk van het bedrijf. De reasoning-stap die het antwoord opstelt, de tools aanroept en beslist wanneer een order geparkeerd moet worden, draait op een hosted model met tool use. Die twee combineren bracht onze kosten per gesprek met een factor vier omlaag zonder meetbaar kwaliteitsverlies.

Als je elke "lokaal model"-post op HN als een binaire keuze leest, kies je verkeerd. De interessante vraag is welke stap van je pipeline lokaal kan zonder de andere stappen te breken.

Acht weken later, in cijfers

Cijfers uit week acht van productie, uit de eigen metrics van de agent-service:

  • 1.840 chat-gedreven bestellingen-vragen per week
  • 312 ms mediaan artikel-lookup, 471 ms p95
  • 11,4% van de gesprekken geparkeerd in de accountmanager-queue
  • Nul writes naar de legacy MySQL vanaf het agent-pad
  • Twee dubbele SAP-orders, beide vóór de session-mutex fix
  • Eén incident: een fulltext index rebuild tijdens kantooruren veroorzaakte een vertraging van 14 minuten

De orderbalie doet nu uitgaande telefoontjes en uitzonderingen. Ze raakten geen baan kwijt. Ze raakten het deel van hun baan kwijt waarin ze 60 keer per dag hetzelfde antwoord over bloemleveringen moesten intypen.

De kleinste versie die je volgende week live kunt zetten

Als je op een verouderde stack zit die je niet mag herschrijven, is het playbook kort. Eén: kies een read-only API-laag en laat de agent daar nooit doorheen schrijven. Twee: cache de catalogus in-memory en ververs op een schema. Drie: leg de menselijke regels ("geen korting boven 18%") vast in de tool-definities, niet in de prompt. Vier: zet het geparkeerde werk in een queue waar een echt mens al naar kijkt.

Toen we de chat agent voor de Zaandamse groothandel bouwden zat het lastige niet in de leeftijd van CodeIgniter, maar in de SAP B1 session refresh onder load. Dat losten we op met een mutex en een idempotency key. Zit jij op een vergelijkbare stack met een vergelijkbare berg herhaalvragen, dan staat op onze pagina over AI-agents de langere versie van hoe we dit soort werk aanpakken.

Open morgenochtend je klantenservice-inbox. Tel hoeveel van de eerste 50 berichten dezelfde vijf vragen stellen. Dat is je shortlist.

Kern

Beperk de tools, niet de prompt: een agent die niet naar je legacy database kan schrijven, kan hem ook niet breken. Dat is wat een twaalf jaar oude stack veilig maakt om mee te chatten.

FAQ

Waarom het CodeIgniter 2.2-systeem niet gewoon herschrijven?

De eigenaar wilde dat niet. Het systeem werkt, de accountmanagers kennen het uit hun hoofd en het maandrapport klopt tot op de cent. Een agent erbovenop is goedkoper en veiliger als de legacy stack stabiel is en het team hem vertrouwt.

Hoe voorkom je dat de agent naar een legacy database schrijft?

Beperk de tools. Bied de agent alleen lees-endpoints aan en laat elke schrijfactie via een moderne API lopen, zoals de SAP B1 Service Layer. Het model kan de legacy MySQL letterlijk niet bereiken met een INSERT.

Wat belandt er in de accountmanager-queue?

Alles waar de voorgestelde korting boven de 18% uitkomt, of waar het vertrouwen in de intentie van de klant onder een ingestelde drempel zakt. Een mens keurt het binnen één werkdag goed of af vanuit een dashboard.

Werkt dit patroon ook zonder SAP Business One?

Ja. Het patroon is generiek: legacy lees-API, in-memory catalogus-cache, tools met ingebakken regels, queue voor menselijk oordeel, modern write-endpoint. Het schrijfdoel kan SAP, Exact, AFAS of je eigen systeem zijn.

Wat is de artikel-lookup latency in productie?

312 ms mediaan en 471 ms op het 95e percentiel, end to end inclusief de tool call. Dat getal blijft daar dankzij een in-memory catalogus-cache, een ngram fulltext-index en een prijsstaffel die per session wordt vastgehouden.

chat agentscase studyphplegacy sitesintegrationsautomation

Iets bouwen?

Start een project