Chat agents
Magento 2.3 chat agent: 1.680 maatwerk-orders vóór 17:00
Het is 16:42 in Apeldoorn. De Rotterdamse werkplaats vergrendelt over achttien minuten de productieplek van morgen. De chat agent moet eerst acht maatwerk-vragen beantwoorden.

Het is 16:42 in Apeldoorn. De Rotterdamse werkplaats vergrendelt over achttien minuten de productieplek van morgen. Er staan acht maatwerk-vragen in de chat agent, twee daarvan over een stof die de winkel in 2019 uit het assortiment heeft gehaald. Om 17:00 heeft elke klant ofwel een levertijdbelofte ofwel een notitie in de plannerwachtrij. Het team telt 24 mensen. Vorig jaar hoorde diezelfde 16:42 bij één steeds vermoeider klantenservice-lead met drie browsertabs en een Magento-backoffice die negen seconden nodig had om te verversen.
De stack is op papier eentje waar geen consultant aan zou willen komen: Magento 2.3.7 op PHP 7.2, een eigen PHP-stoffenbibliotheek, MySQL 5.7. De agent handelt nu 1.680 maatwerk-gesprekken per week af. Bij geen daarvan hoeft een mens het eerste antwoord te typen. Het interessante werk zat niet in het taalmodel. Het zat in het contract tussen de agent, de planning van de Rotterdamse werkplaats en een dertien jaar oude database die nog niemand klaar is om weg te zetten.
De stack die we erfden
Magento 2.3 is in september 2022 door Adobe uit support gehaald; sindsdien staan de beveiligingsadviezen netjes gecatalogiseerd op de Adobe Commerce security bulletins-pagina. PHP 7.2 verloor in november 2020 zijn security support, zoals te lezen op de PHP supported versions-pagina. De eigenaar wist het allang. Hij wist ook dat de site converteerde, dat Stripe doorliep, en dat de Rotterdamse werkplaats precies wist welke rij in fabric_stock_extended bij welke rol in welk rek hoorde. De komende achttien maanden zouden niet over het herbouwen van de checkout gaan. Ze zouden over volume gaan. Wij gingen niet het bureau worden dat een lopende winkel breekt om zich modern te voelen.
De opdracht was dus smaller: haal de maatwerk-vraag — "kun je deze bank in deze stof in deze maat leveren vóór datum X" — van de stapel van de klantenservice af, zonder de checkout, de planner of de stoffenbibliotheek aan te raken. Sidecar, geen ingreep.
Wat de agent eigenlijk doet
De agent antwoordt in het Nederlands. Hij leest drie dingen: het product waar de klant naar kijkt, de stoffenbibliotheek (4.217 SKU's verdeeld over zijde, wolmix, velours en outdoor) en de planning van de Rotterdamse werkplaats. Hij schrijft één van drie dingen terug: een offerte met een vastgelegde leverweek, een ticket in de plannerwachtrij met een nette houdboodschap, of een doorzet naar een mens als de vraag helemaal niet over maatwerk gaat (dat gebeurt twee keer per dag, meestal over een zoekgeraakte levering — daar is de agent niet voor).
De orkestratielaag is een dunne Node-service die op dezelfde VPS draait als Magento. Hij praat niet met de REST API van Magento voor de voorraad — die was traag en loog over gereserveerde rollen. Hij praat read-only direct met MySQL, tegen dezelfde tabellen die de eigenaar al dertien jaar leest. De eerste versie van de retrieval was Postgres met pgvector. Die hebben we in week twee eruit gegooid en herbouwd op de bestaande fulltext-indexen van MySQL, want daar zat de waarheid en de latency was 40 ms in plaats van 220 ms.
De SLA van 45 seconden
Het harde getal is 45 seconden. Vanaf het moment dat een klant zijn eerste bericht stuurt, moet de agent ofwel een leverweek vastleggen ofwel het gesprek in de plannerwachtrij parkeren. Niet "liefst wel". Mechanisch. De orchestrator heeft een budgettimer en de modelcall zit in een thinking-time-guard. Staat de agent op 38 seconden nog te denken, dan stopt hij met denken en parkeert. Het plannerteam verwerkt liever vijftien extra tickets per dag dan dat één klant 90 seconden zit te wachten op een twijfelend model.
De 45-secondengrens telt vanwege de 17:00-deadline. De voorman van de werkplaats in Rotterdam vergrendelt de productieplek van morgen om 17:00 stipt, Europe/Amsterdam-tijd. Wat dan niet met een bevestigde stof in de plannerwachtrij staat, rolt door naar de volgende dag, en daarmee schuift de hele leverweek één op. Over honderd gesprekken op een vrijdagmiddag is dat optelsommetje hard. Het contract van de agent met de werkplaats is: ik laat je nooit wachten, en ik beloof nooit iets wat ik niet kan parkeren.
// rules/leadtime-park.ts
export async function decide(quote: Quote, ctx: Ctx) {
const weeks = await leadTimeWeeks(quote, ctx);
if (weeks > 8) {
await ctx.planner.queue({
quoteId: quote.id,
reason: 'leadtime>8w',
slot: 'next-available',
lockBeforeUtc: ctx.cutoff(), // 17:00 Europe/Amsterdam
});
return {
sayToCustomer:
`We kunnen dit maken. De eerstvolgende plek is over ${weeks} weken. ` +
`Onze planner bevestigt binnen één werkdag.`,
handoff: 'planner',
};
}
return {
sayToCustomer:
`Ja. Levering over ${weeks} weken als je vandaag vóór 17:00 bevestigt.`,
handoff: null,
};
}
De acht-wekenregel
Elke levertijdbelofte boven de acht weken gaat de plannerwachtrij in. Daar valt voor de agent niet over te onderhandelen. Het getal is niet magisch; het is het punt waarop het betrouwbaarheidsinterval van de werkplaats op een leverdatum boven de 90% komt, gebaseerd op drie jaar historie die de klant ons in week één in een CSV gaf. Onder de acht weken mag de agent vastleggen. Boven de acht weken alleen een mens.
Wat we leerden: de parkeerboodschap is belangrijker dan de bevestigingsboodschap. Klanten die "je maatwerk staat in de plannerwachtrij, ons team bevestigt binnen één werkdag" krijgen, haken niet af. Klanten die de oude "het wordt waarschijnlijk 9-12 weken, we komen erop terug"-template kregen, haakten op 31% af. De parkeertekst van de agent ging zes herschrijvingen mee voor het uitvalpercentage op geparkeerde gesprekken onder de 4% zakte. De zin die het uiteindelijk deed was de saaie: een vast moment, een eigenaar bij naam en één concrete volgende stap.
RAG over een dertien jaar oude stoffenbibliotheek
De stoffenbibliotheek vrat het meeste tijd. 4.217 SKU's, waarvan er ongeveer 1.100 actief op voorraad liggen. De rest is uit het assortiment, gearchiveerd, of op bestelling te krijgen bij een weverij in Como met zes weken levertijd. Klanten typen dingen als "die donkerblauwe linnen die mijn moeder vorig jaar besteld heeft". De agent moet dat mappen naar ofwel een SKU op voorraad, ofwel een uit-assortiment-SKU met een plausibel alternatief, ofwel een weverij-bestelling met een verse offerte.
We zijn met pgvector begonnen. Prima. Toen merkten we dat de bestaande MySQL fulltext-index van de winkel, gecombineerd met een handmatig onderhouden synoniemenlijst die de klantenservice-lead sinds 2017 in een Google Sheet bijhield, de vector retrieval versloeg op top-1-accuratesse met elf punten. Die Google Sheet is nu een tabel in de database, elke nacht ververst door dezelfde lead, die niet meer brandjes blust maar de synoniemenlaag bezit.
Het zat 'm niet in het algoritme. Het zat 'm in de synoniemen. Klanten in Apeldoorn typen geen SKU-namen. Ze typen de kleur die hun moeder in 2018 had, de structuur die een showroommedewerker in maart noemde, de prijsklasse die ze vaag onthouden. Iets meer dan drieduizend van die koppelingen stonden al in de sheet, opgebouwd telefoontje voor telefoontje over zeven jaar. De vector store had ze moeten leren. De fulltext-index kende ze al bij naam.
-- fabric_lookup.sql — called from the retriever per turn
SELECT
f.sku,
f.name_nl,
f.composition,
f.width_cm,
f.rub_count,
f.discontinued_at,
s.rolls_available,
s.rack_location
FROM fabric_stock_extended f
LEFT JOIN fabric_stock_live s
ON s.sku = f.sku
WHERE
MATCH(f.name_nl, f.search_blob)
AGAINST (? IN NATURAL LANGUAGE MODE)
AND (f.discontinued_at IS NULL
OR f.discontinued_at > NOW() - INTERVAL 24 MONTH)
ORDER BY
(s.rolls_available > 0) DESC,
MATCH(f.name_nl, f.search_blob) AGAINST (?) DESC
LIMIT 12;
Het venster van 24 maanden voor uit-assortiment-stoffen telt: klanten die naar een stof "van een jaar of twee geleden" verwijzen, bedoelen vrijwel altijd iets wat in dat venster is verkocht. Alles wat ouder is, loopt via een apart archiefpad met een duidelijke kanttekening in het bericht aan de klant. Een zijde uit 2014 ophalen voor een klant uit 2026 heeft geen zin.
Wat in week twee stuk ging
Twee dingen gingen stuk in week twee. Het eerste: de agent beloofde vol vertrouwen een bank in een stof die de werkplaats sinds 2021 weigert te gebruiken voor bekleding, omdat de Martindale-wrijftest te laag was voor een zitting. De stoffenbibliotheek had de wrijftestwaarde wel, maar markeerde de stof niet als ongeschikt voor bekleding; die regel zat in het hoofd van de voorman. We hebben het opgelost zoals we dit soort dingen altijd oplossen: de voorman dicteerde de regels, een junior engineer schreef ze als YAML-bestand in de context van de agent, en de voorman herziet dat bestand nu één keer per maand bij een kop koffie. Het zijn er 31. Ze zijn extreem saai. Ze zijn het meest waardevolle artefact in het project.
Een paar voorbeelden, geparafraseerd: geen Martindale onder de 25.000 wrijvingen op een zitkussen. Geen linnen als outdoor-bekleding, ook niet als de klant erop staat. Geen twee geweven patronen op hetzelfde frame zonder showroomfoto met paraaf van de voorman. Geen velours op een daybed in een huishouden met een hond. De regels doen twee dingen tegelijk. Ze houden de agent ervan af om producties te beloven die de voorman op de werkvloer alsnog zou moeten weigeren, en ze maken stilzwijgende werkplaatskennis leesbaar voor een junior engineer die zes weken geleden is binnengekomen en het rek met uit-assortiment-zijde nog nooit van dichtbij heeft gezien.
Het tweede was MySQL-replicatielag op de read-replica waar we de agent op hadden gericht. Onder belasting offreerde de agent af en toe een rol die 90 seconden eerder al door een ander gesprek was gereserveerd. We hebben de voorraadleesactie naar de primary verplaatst, de penalty van 8 ms geaccepteerd en een reserveringshold van 15 seconden toegevoegd zodra een levertijdbelofte valt. Sindsdien geen dubbele boekingen meer.
Als je agent voorraad uit een replica leest, liegen je cijfers onder belasting. De fix is niet "tune de replica" — de fix is "lees nooit voorraad uit een replica".
De cijfers na elf weken
Na elf weken: dit hebben we gemeten. De agent handelt 1.680 maatwerk-gesprekken per week af, tegenover een handmatige baseline van 410 die hetzelfde team aankon. De 17:00-deadline is in elf weken twee keer gemist, beide keren door een storing bij Stripe waardoor de aanbetalingsstap blokkeerde (niet de schuld van de agent, maar de klantenservice-lead trakteerde ons alsnog op een stroopwafel en een strenge blik). 71% van de gesprekken eindigt met een bevestigingsbericht; 23% met een parkeerboodschap richting de plannerwachtrij; 6% met een doorzet naar een mens. Op de geparkeerde gesprekken is het uitvalpercentage 3,8%, tegen 31% op de oude template.
De klanttevredenheid, gemeten met een een-tot-vijf-sterren-prompt na levering, is van 4,1 naar 4,4 gegaan. De modelrekening, per gesprek afgerekend, ligt rond de twintig cent op een goede dag en veertig op een slechte — kortom €40 tot €60 per week voor alle 1.680 gesprekken samen. De klantenservice-lead besteedt haar middagen nu aan het bijhouden van de synoniementabel en het langslopen van het regelbestand van de voorman. Ze werkt niet meer door na 17:30. Ze vertelde ons, met de gepaste hoeveelheid Nederlandse argwaan, dat ze had verwacht het project verschrikkelijk te vinden.
Wat we niet hebben gebouwd
We hebben Magento niet gemigreerd. PHP 7.2 niet aangeraakt. De planner niet vervangen. De eigenaar heeft voor alle drie een eigen tijdlijn, en als hij klaar is, staan we er. Maar dit project is een herinnering dat een chat agent geen moderne stack eronder nodig heeft. Hij heeft een schoon contract nodig met de systemen die de zaak al draaiende houden.
Diezelfde week dat we live gingen, kwam er op Hacker News een open-source AI-CAD-tool genaamd Adam langs die 188 punten naar de voorpagina haalde. Voor een maatwerk-zaak is het gat tussen "ik wil een bank in deze stof in deze maat" en een werkplaats-klare specificatie nog steeds half handwerk. De chat agent dicht de ene kant van dat gat. CAD-agents gaan de andere kant dichten. Dat is een onderwerp om in de gaten te houden voor de volgende fase van de roadmap van deze klant.
Toen we deze agent voor de meubelzaak in Apeldoorn bouwden, was wat we tegenkwamen dit: de stoffenbibliotheek wist alles over de stoffen en niets over hoe ze werden gebruikt. We hebben dat opgelost met een YAML-regelbestand dat de voorman bezit en een synoniementabel die de klantenservice-lead bezit — het soort kleine, duurzame structuur die een verouderde database verandert in iets waar een AI-agent echt mee aan de slag kan.
Het kleinste wat je deze week kunt proberen
Pak de ene vraag die je klantenservice het vaakst beantwoordt. Schrijf het contract — input, output, acceptabele latency, wat te doen als het antwoord "nee" is — op één A4, in lopende tekst. Is je stack vijftien jaar oud, schrijf het contract dan tegen de databasetabellen die je al vertrouwt, niet tegen de API die je had willen hebben. Bouw daarna de agent tegen dat contract, niet tegen het model. Het model is het goedkope deel.
Kern
Een chat agent heeft geen moderne stack eronder nodig. Hij heeft een schoon contract nodig met de systemen die de zaak al draaiende houden.
FAQ
Waarom hebben jullie Magento 2.3 niet gemigreerd voordat jullie de agent bouwden?
De roadmap van de eigenaar voor de komende achttien maanden gaat over volume, niet over een refactor. De site converteert, betalingen lopen door en de werkplaats kent de database. We hebben de agent op de draaiende stack gebouwd in plaats van een werkende zaak kapot te maken.
Kan de agent te veel beloven op leverdata?
Nee. Alles boven de acht weken wordt geparkeerd in de plannerwachtrij voor een mens om te bevestigen. De grens van acht weken is het punt waarop het historische betrouwbaarheidsinterval van de werkplaats op een leverdatum boven de 90% komt.
Waarom MySQL fulltext in plaats van een vector database?
We hebben eerst pgvector geprobeerd. De bestaande fulltext-index van de winkel plus een handmatig onderhouden synoniementabel sloeg het op top-1-accuratesse met elf punten en draaide op 40 ms in plaats van 220 ms. De waarheid stond al in MySQL.
Wat gebeurt er als het model traag is?
De orchestrator heeft een budget van 45 seconden. Op 38 seconden stopt hij met denken en parkeert het gesprek in de plannerwachtrij met een houdboodschap. De werkplaats heeft liever extra tickets dan een wachtende klant.