← Blog

Chat agents

Chat-agent lek: een Slack-incident en onze staging-gate

Donderdag, 14:11. Een concept-persbericht belandt in een publiek Slack-kanaal. De chat-agent had het in een DM moeten zetten. Wat ging er mis, en de gate die wij nu draaien voor staging.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 jun 2026· 8 min
Crème envelop open op ivoren vloei, chartreuse zijden lint, omgevallen messing belletje, gebroken roodbruine lakzegel.

Donderdagmiddag, 14:11. Een accountmanager bij een marketingbureau van 24 mensen in Amersfoort opent Slack. In #general, tussen een vraag over een parkeerplek en een Loom-thumbnail, staat een onverzonden concept-persbericht voor een klant die ze zes weken aan het onboarden waren. Onder embargo. Met een quote van een directielid. Een interne prijsregel die nooit een DM had mogen verlaten. Geplaatst door de chat-agent.

De agent draaide elf dagen in productie.

De agent van elf dagen oud

Het bureau draait een kleine maar dichte operationele stack: HubSpot, Notion, Google Workspace, Slack. Hun chat-agent doet wat de meeste bureaus willen dat een chat-agent doet. Hij schrijft concept-follow-ups naar klanten, signaleert openstaande facturen, en plaatst interne status-pings in de juiste DM zodra een campagne-asset een mijlpaal haalt. Hij plaatst niets in publieke kanalen. Dat was de spec.

Het systeem werkte. Tien en een halve dag werkte het bijzonder goed. Het team hield een Notion-pagina bij met de successen van de agent. Het scheelde grofweg vier uur per week per accountmanager, en de eigenaar van het bureau rekende dat in echt geld om.

Toen kwam donderdag.

Wat er echt brak

De agent roept de Slack chat.postMessage endpoint aan via een dunne tool-wrapper die wij om de officiële Slack Web API hebben geschreven. De documentatie van Slack is expliciet: channel is een verplicht veld. Je kunt geen bericht plaatsen zonder.

Onze wrapper was echter niet expliciet. Drie dingen kwamen samen:

  1. Het JSON-schema dat we naar het model uitstuurden, markeerde channel_id als "required": false. Een copy-paste uit een interne demo waar de wrapper een sandbox-kanaal hard-coded had staan.
  2. De wrapper zelf had een fallback: channel_id = payload.channel_id ?? config.default_channel. De default, gezet tijdens diezelfde demo, was #general. Niemand had hem weggehaald.
  3. De system prompt zei tegen de agent dat hij in het default-kanaal van het team moest posten als er geen kanaal werd opgegeven. Drie verschillende bronnen van permissiviteit, ieder op zichzelf nog te verdedigen.

Op die bewuste donderdag werd de agent gevraagd een persbericht te schrijven en het naar de brand lead te sturen voor review. Het model produceerde een tool call met recipient: "Marije", die de wrapper had moeten vertalen naar een DM. Het schema vereiste geen channel_id. Het model liet het weg. De wrapper paste de default toe. #general kreeg een persbericht.

Waarschuwing

Een LLM behandelt een required veld als verplicht. Een optioneel veld met een default behandelt hij als uitnodiging om het weg te laten. Je schema is je contract; de prompt is een suggestie eroverheen.

Waarom required niet genoeg is

Toen we de post-mortem teruglazen, was het probleem niet het model. Het model gedroeg zich exact zoals het schema uitnodigde. Het is dezelfde categorie issue die OWASP catalogiseert als excessive agency in hun LLM Top 10: een tool heeft meer kunnen dan de taak vraagt, en de agent gebruikt dat uiteindelijk.

Wat ons verraste, en wat het waard is om te benoemen voor iedereen die in 2026 een chat-agent voor klanten draait, is hoe alledaags het faalpad was. Geen jailbreak. Geen prompt injection. Geen slimme exfiltratie. Een required veld stond als optioneel, een default was op een publiek kanaal gezet, en iedere fix op zich had het incident voorkomen. Samen leverden ze een persbericht aan vierentwintig mensen.

We trokken het bericht binnen negentig seconden terug. De klant kreeg het diezelfde dag te horen. De eigenaar van het bureau handelde het netjes af. De schade was reputationeel, beperkt, en te overleven. We hadden geluk.

De gate in drie stappen die we nu draaien

We zetten geen chat-agent voor klanten op staging zonder drie checks. De gate is bewust kort. Hij bestaat zodat het soort fout dat we op donderdag zagen, niet meer bij een klant terechtkomt.

1. Schema-strictheid

Elke tool die de agent kan aanroepen, krijgt zijn schema opnieuw afgeleid uit één bron van waarheid. We pinnen additionalProperties: false, markeren ieder veld dat de upstream API als verplicht behandelt ook in onze wrapper als required, en verwijderen elke fallback-default voor routing-velden (channel, recipient, address, account_id). De check is geautomatiseerd. CI laat de build falen als een wrapper een routing-veld als optioneel blootstelt.

// tools/slack/post-message.ts
import { z } from "zod";
import { slack } from "../../clients/slack";

export const postMessageSchema = z
  .object({
    channel_id: z.string().regex(/^[CDG][A-Z0-9]{8,}$/),
    text: z.string().min(1),
    thread_ts: z.string().optional(),
  })
  .strict();

export async function postMessage(input: unknown) {
  const args = postMessageSchema.parse(input); // throws on missing channel_id
  return slack.chat.postMessage({
    channel: args.channel_id,
    text: args.text,
    thread_ts: args.thread_ts,
  });
}

Het Zod-schema is wat we naar het model uitsturen, waar de wrapper tegen valideert, en wat CI inspecteert. Eén artefact, drie gebruiken. Er is geen plek waar een routing-veld stiekem optioneel kan worden.

2. Permissie-matrix

De tweede check is een expliciete allow-list. Voor elke agent die we uitleveren, schrijven we een matrix van welke tools tegen welke targets mogen worden aangeroepen. Saai, declaratief, en onmogelijk voor het model om te overrulen.

# agents/agency-bot/permissions.yaml
agent: agency-bot
slack:
  chat.postMessage:
    channels:
      allow:
        - "^D[A-Z0-9]+$"        # direct messages only
      deny:
        - "^C[A-Z0-9]+$"        # all public channels
        - "^G[A-Z0-9]+$"        # all private channels
hubspot:
  contacts.update:
    fields:
      allow: ["lifecycle_stage", "last_contacted"]
      deny: ["email", "phone"]

De wrapper checkt de matrix voor elke call. Probeert de agent een bericht in een publiek kanaal te plaatsen, dan wordt de call bij de wrapper geweigerd, krijgt het model te horen dat hij geweigerd is, en wordt de poging gelogd. Het model beslist niet of de regel van toepassing is. De tool-use gids van Anthropic is duidelijk dat een model gestuurd kan worden, maar niet gebonden door instructies alleen; de binding moet in de omliggende code zitten.

3. Dry-run van uitgaande calls

De derde check is degene die de meeste teams overslaan. Voordat een agent naar staging gaat, draaien we een corpus van honderd tot tweehonderd prompts opnieuw af, getrokken uit echte gespreksgeschiedenis die de agent zal tegenkomen. We loggen elke tool call die het model zou hebben gedaan en checken de targets tegen de permissie-matrix. Het corpus bevat adversariële prompts, toevallige dubbelzinnigheden, en een kleine set prompts die eerder incidenten veroorzaakten bij andere agents.

De dry-run is goedkoop. Hij draait in een sandbox-account zonder netwerktoegang tot klantsystemen. Hij vangt de saaie fouten: een wrapper die een recipient verkeerd resolved, een system prompt die een default noemt die er niet meer is, een tool die nog aan het model wordt blootgesteld die niemand zich herinnerde te verwijderen. Ongeveer één op de twaalf dry-runs signaleert een echt probleem. Die ratio is stabiel gebleven over de veertien agents die we nu in productie draaien.

Onthoud dit

Het model is niet de perimeter. De wrapper is dat. Heeft je wrapper een fallback-default voor een routing-veld, dan heeft je agent een lek dat wacht op de juiste donderdag.

Wat we in de wrapper veranderden

Voor de agent in Amersfoort kostte de fix een middag. We verwijderden het veld default_channel uit de config. We maakten channel_id verplicht in het Zod-schema. We voegden de permissie-matrix toe en koppelden de wrapper zo dat hij die afdwong. Het opbouwen van het dry-run-corpus uit de eerste elf productiedagen kostte een dag. Totale tijd tot een herstelde, te shippen staat: veertig uur, inclusief het gesprek met de klant en de interne post-mortem.

Het bureau hield de agent. De eigenaar was, terecht, meer geïnteresseerd in de gate dan in de excuses. We draaien die gate nu op elke chat-agent die we bouwen, ongeacht de grootte van de klant. Het is het soort proces dat als overkill leest als je erover leest, en als vanzelfsprekend voelt als je het alternatief hebt meegemaakt.

Wat je vanmiddag kunt doen

Als je een agent draait die een tool aanroept met een routing-veld (channel, recipient, account, customer_id), grep je wrapper dan op default, fallback en ??. Elke regel waar een routing-veld een fallback krijgt, is een kandidaat voor exact het incident dat wij op donderdag zagen. Haal de fallback weg. Maak het veld required in het schema. Laat het model luid falen als hij het vergeet, want een luide fout komt boven in je logs en een stille default komt boven in de Slack van je klant.

Toen we de chat-agent bouwden voor het bureau in Amersfoort, was de fout die ons deze gate leerde één regel permissieve config die was blijven staan uit een demo van zes maanden eerder. We hebben het opgelost door die regel te verwijderen, het schema strakker te trekken, en de dry-run te schrijven die nu draait op elke AI-agent die we uitleveren.

Kern

De perimeter van een chat-agent is zijn tool-wrapper, niet zijn system prompt. Heeft de wrapper een fallback voor een routing-veld, dan staat het lek al in de agenda.

FAQ

Hoe komt een chat-agent terecht in een kanaal waar hij niet mag posten?

Bijna altijd via een te permissieve tool-wrapper. De system prompt is een richtlijn; de wrapper is de poort. Als de wrapper een fallback-default heeft voor het channel-veld, raakt de agent die uiteindelijk.

Is een strikte system prompt niet genoeg om de agent in het gareel te houden?

Nee. Modellen volgen prompts probabilistisch. Code volgen ze onvoorwaardelijk. Elke bindende regel over welke tools tegen welke targets mogen worden aangeroepen, hoort in de wrapper, niet in de prompt.

Hoeveel voegt de gate van drie stappen toe aan een typisch agent-project?

Ongeveer één tot twee dagen engineering vooraf en grofweg dertig minuten per release. Het verdient zichzelf terug zodra de dry-run de eerste keer een regressie opvangt vóór staging.

Hoe ziet een dry-run van uitgaande calls er in de praktijk uit?

Speel honderd tot tweehonderd echte prompts opnieuw af door de agent in een sandbox, log elke tool call die hij probeert te doen, en vergelijk elk target met de permissie-matrix. Iedere call die een geweigerd target zou raken, is een bug om vóór staging op te lossen.

Had een JSON-schema met additionalProperties false dit kunnen voorkomen?

Het had geholpen, maar de echte fix is routing-velden als required markeren en fallback-defaults uit de wrapper verwijderen. Strictheid hoort zowel in het schema als in de omliggende code te zitten.

chat agentsai agentsintegrationssecurityarchitectureoperations

Iets bouwen?

Start een project