← Blog

Voice agents

Voice agent-incident: 1.140 verouderde tarieven om 13:00

Woensdag, 13:04 uur. Een voice agent op de SIP-trunk van een Groningse energieleverancier had net het verkeerde variabele tarief geoffreerd aan 1.140 huishoudens in 41 minuten.

Jacob Molkenboer· Oprichter · A Brand New Company· 19 jun 2026· 9 min
Zwarte bakelieten telefoonhoorn op groen leren onderlegger, groen lint, paper met lakzegel, koperen bel op achtergrond.

Het was 13:04 op een woensdag in februari. De shiftleider bij een energieleverancier van 24 mensen in Groningen zag het call-dashboard oplopen. 1.140 uitgaande voice agent-gesprekken afgerond in de afgelopen 41 minuten, elk ervan een bevestiging van een variabel-tariefaanbod aan een huishouden waarvan het contract aan verlenging toe was. Ze had een koffie in de ene hand en een knoop in haar maag, want de EPEX day-ahead price-feed was net om 13:00 ververst en het nieuwe plafond lag 11 cent hoger dan het bedrag dat de agent sinds 12:23 had geoffreerd.

De agent was van ons. De 90 seconden TTL op de gecachte feed was van ons. De Claude tool-use loop die de cache opnieuw ophaalde in plaats van te invalideren was van ons. Dit is de post-mortem.

De pipeline vóór het incident

We hadden zes maanden eerder een Nederlandstalige uitgaande voice agent voor de leverancier gebouwd. Twilio SIP-trunk, onze eigen ASR + TTS routing, Claude Sonnet 4.5 als brein, en een kleine toolset voor tariefopzoeken, contractverificatie, en overdracht naar een mens als de klant iets zei dat naar een klacht rook. De uitgaande belasting was bescheiden: ruwweg 300 gesprekken per dag, vooral verlengingsherinneringen voor contracten die hun twaalfde maand naderden.

De tariefopzoekfunctie was de enige tool die ertoe deed. De klant vroeg, in een of andere vorm: "wat ga ik betalen". De agent riep dan get_current_tariff(postal_code, contract_type) aan. Die tool raakte onze interne pricing service, die zelf las uit een Redis-cache die werd gevuld door een worker die elke minuut de EPEX SPOT day-ahead feed polde.

Cache TTL: 90 seconden.

Waarom 90 seconden? Omdat de upstream feed gerate-limit was op 60 requests per minuut over de hele leverancier, en drie van hun zusterproducten deelden dezelfde quota. Negentig seconden gaf ons ademruimte en betekende dat een klant nooit een prijs zou horen die meer dan 90 seconden verouderd was. We draaiden zo al vijf maanden zonder klacht.

Wat er om 13:00 in de Nederlandse stroommarkt gebeurt

Iedereen die in de Nederlandse energie werkt, kent het ritme. De EPEX day-ahead veiling sluit om 12:00 CET. De clearingprijzen voor de volgende 24 uur worden kort daarna gepubliceerd. Tot die prijzen er zijn, draaien leveranciers op de curve van de vorige dag. Zodra ze er zijn, meestal tussen 12:45 en 13:05, verschuift de curve en moet elk variabel-tariefaanbod de nieuwe bandbreedte weerspiegelen.

De ACM, de Nederlandse mededingingsautoriteit, geeft richtlijnen over hoe helder en hoe prompt die verschuiving in klantgerichte aanbiedingen moet worden weerspiegeld. Een millisecond-SLA schrijft ze niet voor, maar de strekking is duidelijk: als je een prijs noemt, moet dat de prijs zijn die je op dat moment ook echt kunt leveren.

Onze voice agent quoteerde een prijs uit 12:23.

De mechanica van de storing

Drie dingen stapelden zich op.

Ten eerste logde de price-feed worker tussen 12:55 en 13:02 een stroom 429 Too Many Requests van het EPEX-endpoint. Het marketingteam van de leverancier had een dagelijkse rapportage-job afgetrapt die dezelfde upstream platwalste. De worker probeerde opnieuw, raakte het quotaplafond per leverancier, en sloeg zijn refresh-windows van 13:00 en 13:01 over.

Ten tweede was de Redis cache-key gescoopt per postcodeband, niet per publicatievenster. De fetch van 12:23 had 412 verschillende keys gevuld, één per actieve postcodeband, en elk van die keys had zijn eigen 90 seconden TTL die onafhankelijk afliep. Naarmate uitgaande gesprekken binnenkwamen en de agent tarieven opzocht voor verschillende postcodes, kreeg de agent cache hits op de keys die nog warm waren. De warme keys verlengden zichzelf door een agressief read-through-refresh-patroon dat we van een zusterdienst hadden geërfd.

Ten derde, en deze doet pijn, gedroeg de Claude tool-use loop zich op een manier die we niet hadden voorzien. De system prompt van de agent zei in feite: "als het tarief onzeker voelt, verifieer door get_current_tariff opnieuw aan te roepen". Toen EPEX rate-limit-waarschuwingen kort in de tool response-payload lekten (we toonden upstream metadata voor debugging en vergaten die op de productiepath te strippen), riep het model de tool opnieuw aan. De her-aanroep raakte dezelfde warme cache. Het model interpreteerde "twee keer hetzelfde antwoord" als "geverifieerd" en zette door met dat tarief op de lijn.

Tegen de tijd dat de shiftleider het dashboard zag, hadden 1.140 huishoudens een tarief geoffreerd gekregen dat gemiddeld 11,2 c/kWh onder de post-13:00-band lag. De economische exposure, als elk huishouden het aanbod zou aannemen, lag boven de €380.000 over het contractjaar. Ze namen het niet allemaal aan. Ongeveer 7% deed dat. De rest koos voor later terugbellen of werd doorgezet naar een mens.

Het eerste uur

Om 13:07 hebben we uitgaande tariefgesprekken stopgezet. Drie minuten nadat de shiftleider het meldde, door de kill-switch op de SIP-trunk te triggeren die we precies voor dit soort moment hadden gebouwd. We hadden hem exact één keer eerder gebruikt, om hem te testen. Elke klant aan wie het verouderde tarief was geoffreerd, kreeg de volgende dag een callback van een mens, met een excuus en de gecorrigeerde prijs. De leverancier honoreerde de laagste van de twee prijzen voor elk huishouden dat al mondeling had geaccepteerd. Dat ging om ongeveer 78 huishoudens, en de kosten werden als goodwill-gebaar geabsorbeerd.

De kill-switch werkte. De detectie niet. Drie minuten is lang als je 28 gesprekken per minuut factureert.

De freshness-check gate

De fix is niet "verlaag de TTL". Een TTL van 30 seconden had de exposure verkleind, maar niet weggenomen. De upstream rate-limit was de hardere constraint. De fix was: de cache niet meer vertrouwen voor uitgaande gesprekken met een tarief, en in plaats daarvan een positieve freshness-assertion eisen voordat welke zin met een prijs dan ook de SIP-trunk verlaat.

We bouwden een kleine middleware-laag tussen het tool-call-antwoord van de LLM en de TTS-engine. Voordat de synthesizer een getal voorleest dat de response-classifier markeert als tariefgetal, doet de laag een synchrone aanroep naar een tariff_freshness-endpoint. Dat endpoint geeft drie velden terug: de cache-leeftijd in seconden, de meest recente EPEX-publicatietimestamp die de worker heeft gezien, en een boolean safe_to_quote.

De boolean is de gate. Als safe_to_quote false is, leest de synthesizer in plaats daarvan een fallback-zin voor: "Mag ik u een ogenblik later terugbellen met de actuele prijs?".

async def gate_tariff_speech(utterance: str, tariff_value: float | None) -> str:
    if tariff_value is None:
        return utterance

    freshness = await freshness_client.check(
        tariff_value=tariff_value,
        max_cache_age_s=45,
        require_post_publication_window=True,
    )

    if not freshness.safe_to_quote:
        logger.warning(
            "tariff.quote.gated",
            cache_age_s=freshness.cache_age_s,
            last_publication=freshness.last_publication_ts,
            reason=freshness.reason,
        )
        return FALLBACK_DUTCH_CALLBACK_LINE

    return utterance

De require_post_publication_window=True-vlag is het deel dat een 13:00 EPEX-rollover opvangt. Het endpoint weigert tussen 12:55 en 13:10 een quote te clearen, tenzij het kan bevestigen dat de worker binnen dat venster een publicatie-gestempelde EPEX-response heeft binnengehaald. Lukt dat niet, dan trip de gate, zegt de agent de callback-zin, en laat het gesprek het tariefsegment netjes vallen.

Let op

Een cache die zijn eigen TTL kan verlengen, is een cache die liegt in precies die vensters waarin je hem het hardst nodig hebt om de waarheid te vertellen. Read-through-refresh is prima voor een productcatalogus. Voor iets met regulering niet.

Drie runbook-aanpassingen die bleven hangen

Het upstream rate-limit-budget verhuisde van de gedeelde leverancier-quota naar een eigen key voor de voice agent. Dat kostte de leverancier €180 per maand en kocht gegarandeerde headroom tijdens publicatievensters. Goedkoop.

De Claude tool-use loop kreeg een strenger contract over wat geldt als "geverifieerd". We voegden een verification_token toe die door de freshness-service wordt teruggegeven en die een quote koppelt aan een specifieke cache-fetch en publicatievenster. Het model krijgt de instructie een her-aanroep niet als bevestiging te zien, tenzij het token overeenkomt. Het upstream metadata-lek is gepatcht. Productie-tool responses strippen nu alles wat geen prijs en geen freshness-payload is.

Het 13:00-venster is nu een onderhoudsvenster. Uitgaande gesprekken worden standaard gepauzeerd tussen 12:55 en 13:08. Het marketingteam van de leverancier ging zonder veel discussie akkoord toen we ze lieten zien wat die 41 minuten hadden gekost.

Het moment van waarheid is het moment van spraak

De freshness-check-middleware voegde ongeveer 80ms synchrone latency toe aan elke uitspraak met een tarief. De gemiddelde response-latency van de agent ging van 1,4s naar 1,5s. Klanten merken het niet. De leverancier draaide drie weken gesprekken na de fix voor de volgende EPEX-rollover. Er is geen tweede incident geweest.

De algemeen toepasbare les: voice agents die gereguleerde getallen noemen, moeten het moment van spraak behandelen als het moment van waarheid. Niet het moment van de tool-call. Niet het moment van LLM-completion. Het moment waarop de SIP-trunk tekst in audio omzet, is het laatste punt waarop je kunt weigeren te liegen. Bouw de gate daar.

Toen we de voice agent voor deze energieleverancier bouwden, was de storingsklasse waar we tegenaan liepen er een die in geen enkele unit-test boven water komt. Een stille, langzame drift tussen cache-staat en realiteit, ingezet als wapen door een beleefde LLM die de cache op zijn woord nam. We hebben het uiteindelijk opgelost door het spraakpad de audit-grens te maken. Wil je dat soort gate op je eigen uitgaande gesprekken, dan is dat het werk dat onze AI-agents-praktijk doet.

De audit van vijf minuten

Draai je een uitgaande voice- of chat-agent die een gereguleerd getal noemt, grep dan je tool responses op upstream metadata die het model nooit zou mogen bereiken. Grep daarna je system prompt op de zin "verifieer door opnieuw aan te roepen". Die twee bevindingen dekken de storingsklasse uit deze post. Heb je er één, dan heb je een 13:04 in je toekomst.

Kern

Voice agents die gereguleerde getallen noemen, moeten de synthesizer als audit-grens behandelen, niet de tool-call. Dat is het laatste punt waar je kunt weigeren te liegen.

FAQ

Waarom niet gewoon de cache-TTL naar een paar seconden verlagen?

Omdat de upstream EPEX-feed gerate-limit was en de cache er was om die limiet te respecteren. Een kortere TTL had het aantal 429's vermenigvuldigd, niet de verouderde quotes weggenomen. De gate moet op het spraakpad zitten, niet in de cache.

Waarom de freshness-check tussen de LLM en de TTS-engine zetten in plaats van in de tool?

Omdat de LLM een tool opnieuw kan aanroepen, over de output kan redeneren, en alsnog op een verouderd getal kan blijven zitten. Gaten bij de synthesizer is de laatste laag waar het model niet omheen kan routeren. Het is het enige punt waar spraak definitief is.

Geloofde het model echt dat een dubbele cache hit als verificatie telde?

In de praktijk wel. Onze prompt nodigde uit tot een her-aanroep bij onzekerheid en onze tool response bevatte geen verificatietoken dat het antwoord aan een publicatievenster koppelde. Twee keer dezelfde payload las als bevestiging. We hebben beide kanten opgelost.

Hoe hebben jullie uitgaande gesprekken in drie minuten stopgezet?

Een vooraf gebouwde kill-switch op de SIP-trunk die nieuwe uitgaande legs laat vallen en lopende gesprekken laat afronden. We hadden hem op dag één gebouwd en één keer getest. Bouw die van jou voordat je hem nodig hebt.

voice agentsai agentsarchitectureoperationscase studyautomation

Iets bouwen?

Start een project