← Blog

Voice agents

Voice agent regiolek: de EU-pinning gate die wij draaien

Op een woensdag om 14:00 in Zwolle zette de voice agent van een recruitmentbureau een salarisindicatie in een openbare ATS-comment. Dit brak er, en de gate die wij nu draaien voor elke tool-call.

Jacob Molkenboer· Oprichter · A Brand New Company· 13 dec 2025· 11 min
Zwarte bakelieten telefoonhoorn op ivoren vloei, gevouwen papieren kaart, groen lint, koperen bel, rode lakzegel.

De recruitment lead van het bureau pingde ons om 14:07. Een kandidaat had haar net via LinkedIn benaderd met de vraag waarom zijn salarisindicatie van €78–84k als openbare comment onder zijn profiel in het ATS van het bureau stond. Hij had het gescreenshot. Hij was, begrijpelijkerwijs, niet blij. De voice agent die wij voor de inbound kandidaatgesprekken van het bureau hadden gebouwd, had het daar vier minuten eerder neergezet, zonder mens in de loop.

De taak van de agent is om intentie, rolvoorkeur en een salarisindicatie vast te leggen, en daarna een gestructureerde samenvatting naar het ATS te pushen als interne notitie. De salarisindicatie is het enige veld dat als AVG-gevoelig is gemarkeerd. Hij handelt de inbound calls af die kandidaten doen wanneer zij na een follow-up van een recruiter terugkomen op een vacature, en hij draaide op het moment van het incident vier maanden live, zonder problemen.

Vandaag belandde de indicatie in het verkeerde veld. En de audio die hem bevatte had onderweg stilletjes de EU verlaten.

De voice pipeline op een normale dag

De opzet is standaard. Twilio SIP naar onze orchestrator, audio-chunks naar een Whisper streaming endpoint, intent- en entity-extractie via een function-calling model, en vervolgens een write naar het ATS via de REST API van het bureau. Elke stap is in configuratie regio-pinned op eu-central-1 (Frankfurt). De ATS-write zelf gebruikt een intern endpoint dat van het openbare comment-veld is gescheiden door zowel URL-pad als OAuth-scope.

Op papier kan de salarisindicatie dus nooit in de openbare comment komen, en kan de audio de EU niet verlaten. Op woensdag braken beide regels. Ze braken onafhankelijk van elkaar, in twee verschillende systemen, en het lek had het nodig dat ze allebei in dezelfde minuut faalden.

De trace van vijf minuten

Wij trokken de request log. Om 13:59:48 gaf de Whisper streaming-verbinding naar eu-central-1 een 429 terug met een Retry-After: 30 header. Het EU-quotum van de vendor was stilletjes overschreden, zonder voorafgaande melding per mail en zonder zichtbaar incident op de status page. De fallback-logica van onze wrapper, zes maanden eerder geschreven toen ditzelfde endpoint kort plat lag, deed de retry zonder de region pin opnieuw af te dwingen. De retry slaagde tegen us-east-1.

De audio van de kandidaat, in helder Nederlands ("ik zit nu op tweeënzeventig, maar voor de juiste rol ga ik richting de tachtig"), ging naar een datacenter in de VS, kwam correct getranscribeerd terug en werd aan het extractiemodel doorgegeven. Onze pipeline keek niet welke regio de audio had getranscribeerd. Hij keek alleen of het transcript binnen 800ms terugkwam.

Dit is het deel van de trace dat wij op een andere dag puur op monitoring hadden gevangen. Ons latency-dashboard voor het voice-pad meet p50 en p99 apart per regio, en een round trip naar de VS vanaf Frankfurt voegt typisch 90–110ms toe ten opzichte van een EU-trip. Maar het dashboard rolt op in vensters van vijftien minuten, en de retry die de Atlantische Oceaan overstak was één call binnen een gesprek van vier minuten. Veertig andere EU-calls in hetzelfde venster hielden het gemiddelde laag. De afwijking was er, maar onzichtbaar op de resolutie waar wij naar keken.

De salarisindicatie werd geëxtraheerd. De ATS-write vuurde. En toen brak het tweede ding.

De OAuth-scope die wij in april verbreedden

De payload van de ATS-write bevatte de salarisindicatie, de gewenste rol van de kandidaat en een korte beleefdheidssamenvatting, gemapt naar aparte velden. De OAuth-token waarover de agent beschikte had een scope die wij in april 2026 hadden verbreed om een feature te ondersteunen die het bureau had aangevraagd: per-veld configuratie van welke velden de agent mocht beschrijven. De configuratie-UI was nog niet uitgerold. Dus de agent had op het moment van de write schrijftoegang tot elk veld in het ATS, inclusief comment_visible, het openbare comment-veld.

De field mapping zelf was door een model gegenereerd. Wij hadden hem een lijst met beschikbare velden met leesbare namen gegeven. Voor de indicatie koos hij salary_band_internal, correct. Maar hij schreef ook een beleefdheidssamenvatting in comment_visible, gegenereerd uit het transcript, met de zin "Candidate currently at €72k, looking towards €80k for the right role."

Twee stille fouten, beide nodig om de data te laten lekken, beide los van elkaar opgelost.

Let op

Een region pin die je één keer in configuratie zet, is geen region pin. Als jouw fallback-logica de retry zonder pin kan doen, kan jouw data uren lang de regio verlaten zonder dat iemand het doorheeft.

Wat wij nu draaien voor elke tool-call

De fix moest onder de agent-laag leven. Een model vragen "blijf alsjeblieft in de EU" is geen control; dat is een gevoel. Dus bouwden wij een gate in drie stappen die tussen de orchestrator en elke tool-call zit die AVG-geclassificeerde data raakt. Elke voice agent die wij nu uitleveren loopt er doorheen.

Stap 1: pre-flight region-check

Voordat de orchestrator een verbinding opent naar een model-endpoint, resolvt hij de hostname van het endpoint en checkt elke teruggekomen IP tegen een EU-allowlist die wij onderhouden vanuit de gepubliceerde RIPE-ranges. Valt ook maar één geresolvede IP daarbuiten, dan faalt de call gesloten. Geen fallback naar een andere regio, geen retry, geen "de EU-versie ligt eruit, dan maar het op één na beste." De call retourneert een fout aan de orchestrator, de orchestrator logt het incident, en er gaat een Slack-page af.

// pre-flight region pin — runs before any AVG-classified tool-call
import { resolve4 } from 'node:dns/promises'
import { ipInEuBlock } from './ripe-eu-blocks'

export async function assertEuEndpoint(url: string): Promise<void> {
  const host = new URL(url).hostname
  const ips = await resolve4(host)
  const nonEu = ips.filter(ip => !ipInEuBlock(ip))
  if (nonEu.length > 0) {
    throw new RegionPinError(
      `Endpoint ${host} resolved to non-EU IPs: ${nonEu.join(', ')}`
    )
  }
}

Stap 2: een API-key per regio, geen header per regio

De schoonste residency-control is het credential zelf. Onze EU-modelkey kan geen US-endpoint aanroepen en andersom, want de vendor handhaaft dat aan hun kant. Wij provisioneren aparte keys per regio, slaan ze in aparte secret-manager paden op, en de wrapper weigert een non-EU key te laden zodra de request als AVG-geclassificeerd is getagd. Bij vendors die alleen een residency-header bieden en geen scoped keys, behandelen wij die header als adviserend en zetten hem alsnog, maar de IP-check in stap 1 is waar wij echt op vertrouwen.

Stap 3: post-response audit

Na elke modelrespons die AVG-data verwerkt, checken wij de region-header van het antwoord (de meeste vendors leveren er één: x-region, openai-processing-region, of vergelijkbaar). Ontbreekt de header, of staat de waarde niet op onze EU-allowlist, dan wordt de respons niet alleen gelogd. Hij wordt weggegooid. Het transcript bereikt het extractiemodel niet. De ATS-write vuurt niet. De orchestrator retourneert een fout aan de caller en het gesprek eindigt met een hoorbare verontschuldiging aan de kandidaat: "Sorry, ons systeem heeft op dit moment problemen, een collega belt je binnen het uur terug." Liever het gesprek laten vallen dan de data verkeerd behandelen.

Kern

Fail closed, niet open. Een voice agent die het gesprek laat vallen als hij EU-residency niet kan bewijzen, is een voice agent die de volgende ochtend geen meldplichtig datalek veroorzaakt.

Wat wij naast de gate hebben aangepast

Uit de post-mortem kwamen nog drie dingen, en die wegen net zo zwaar als de gate zelf.

Eén: wij gebruiken geen model-gegenereerde field mappings meer voor AVG-gevoelige writes. Die mappings worden nu met de hand geschreven, in code review gecheckt, en zitten achter een per-veld allowlist. De agent mag vragen om naar salary_band_internal te schrijven; hij mag niet zelf besluiten om ook een samenvatting in een openbaar comment-veld te zetten. Het model wikt, de allowlist beschikt.

Twee: wij hebben de OAuth-scope teruggebracht naar de expliciete set velden die het bureau op het moment van token-uitgifte had geconfigureerd. De "schrijf elk veld" scope was lekker voor onze roadmap en dodelijk voor het principe van least privilege. Wij hadden stilzwijgend een venster van zes maanden geaccepteerd waarin één fout overal kon schrijven. Dat venster is dicht.

Drie: wij scrapen nu de rate-limit response headers bij elke succesvolle call naar een model-vendor en dashboarden ze per regio. De 429 die dit incident in gang zette stond nooit op de publieke status page van de vendor, maar het regionale quotum was de voorafgaande veertig minuten zichtbaar aan het leeglopen in de X-Ratelimit-Remaining header. Wij hadden kunnen zien wat de on-call van de vendor zag, op hetzelfde moment, als wij naar het juiste veld hadden gekeken. Wij pagen nu wanneer het EU-quotum onder 25% van het dagplafond zakt, en sturen een Slack-melding met lage prioriteit zodra het de 50% passeert. Het quota-dashboard staat naast het residency-dashboard, want de twee fouten zijn van dezelfde klasse: een conditie aan de vendor-kant die een vendor-side fallback kan dichtsmeren zonder je iets te vertellen.

De meldplicht datalek, en wat wij het bureau vertelden

De salarisindicatie was binnen negen minuten na het screenshot van de kandidaat in onze Slack uit de openbare comment getrokken. Het bureau diende binnen 24 uur een AVG-melding in, ruim binnen de 72-uurslimiet die artikel 33 van de GDPR oplegt voor elk lek van persoonsgegevens, en die in Nederland door de Autoriteit Persoonsgegevens apart wordt bijgehouden. De kandidaat accepteerde het excuus en bleef in het proces, al heeft hij de rol uiteindelijk niet aangenomen.

De recruitment lead van het bureau stelde ons na afloop één vraag, dezelfde die wij na zo'n incident van elke operations lead krijgen: "Hoe hadden wij dit zelf kunnen vangen?" Het eerlijke antwoord is: dat hadden jullie niet. De vendor gaf een 200, het transcript klopte, de ATS-write slaagde. Elk systeem stond op groen. Het enige dat de drift eerder had gevangen, was een synthetische check die elke minuut het model-endpoint resolvde en paged zodra de IP buiten de EU-allowlist landde. Die draait nu, op een aparte cron, in een aparte cloud, op een apart alertingkanaal.

De AI-native infrastructuurvraag

Er gaat deze week op Hacker News een populair AI-native startup-playbook rond dat stelt dat je vendor-API's snel aan elkaar moet knopen en infrastructuur als later probleem moet behandelen. Dat is een redelijke houding wanneer de infrastructuurvraag is of je Postgres zelf gaat hosten. Het is geen redelijke houding wanneer de infrastructuurvraag is op welk continent de salarisdata van een kandidaat staat terwijl hij wordt getranscribeerd.

Als jij voice agents tegen EU-kandidaten, klanten of patiënten draait, is de gate tussen jouw orchestrator en jouw vendors jouw probleem, niet dat van de vendor. De vendor zal jouw call met plezier naar een andere regio doorzetten om jouw latency laag te houden, en in het rapport dat je daarna krijgt staat dat alles prima ging.

Eén ding dat je vandaag kunt doen

Toen wij de voice agent voor dit Zwolse recruitmentbureau bouwden, was waar wij tegenaan liepen dat een region pin in configuratie nog geen region pin op request-tijd is. Wij hebben dat opgelost met de gate in drie stappen die nu voor elke AI-agent staat die wij uitleveren.

Als jij agents draait die EU-persoonsgegevens raken, is de kleinste audit die je vandaag kunt doen een trace van vijf minuten op één productie-call: log de IP waarnaar jouw model-endpoint daadwerkelijk resolvde, en check die tegen de gepubliceerde EU-ranges. Valt hij erbuiten, dan heb je een probleem waar je nog niet van wist.

Kern

Een region pin die in configuratie staat, is geen region pin — jouw fallback-logica kan EU-voice-data naar een ander continent lekken zonder dat iemand het merkt.

FAQ

Waarom faalde de EU region pin überhaupt?

Onze fallback-logica, geschreven voor een eerdere outage, deed na een 429 de retry zonder de region pin opnieuw af te dwingen. De retry slaagde tegen een US-regio en de rest van de pipeline heeft het niet gecheckt.

Is de Europese data residency van OpenAI op zichzelf genoeg?

Het helpt, maar het is geen handhaving. Tot je bij elke call de IP en de region-header van de respons checkt, kan een vendor-side fallback of verkeerd gerouteerde key alsnog data buiten de EU sturen zonder dat je het merkt.

Hoe snel moeten wij een meldplicht datalek indienen?

Onder artikel 33 van de GDPR heb je 72 uur vanaf het moment dat je je bewust wordt van het lek om de toezichthouder te informeren. In Nederland is dat de Autoriteit Persoonsgegevens.

Voegt de gate latency toe aan een voice-call?

De pre-flight IP-check wordt per hostname 60 seconden gecached en voegt bij een cache hit zo'n 4ms toe. De post-response header check is een string compare en kost niets. Kandidaten merken er niets van.

voice agentsai agentssecuritycase studyoperationsintegrations

Iets bouwen?

Start een project