Process automation
Process-automation playbook: 3 ATS-systemen, één queue
Een senior recruiter heeft om 18:47 drie ATS-tabs open, dezelfde kandidaat in twee ervan en nog 400 cv's te gaan. Dit is het playbook waarmee we het oplosten.

Het is dinsdag 18:47 in Tilburg. Een senior recruiter heeft drie browser-tabs open: Bullhorn voor de pipeline, Otys voor de vacaturefeeds, en Carerix omdat de plaatsingsgeschiedenis van haar klant daar al twaalf jaar staat. Dezelfde kandidaat duikt in twee van die tabs op, onder iets afwijkende spellingen van zijn achternaam. In haar inbox liggen 400 ongelezen cv's. Ze pakt er één op, ziet een duplicaatmelding, legt hem weg om het duplicaat in Carerix samen te voegen, en tegen de tijd dat ze terug is, is ze de draad van de eerste kandidaat kwijt. Dit is het process-automation-werk waarvoor wij werden ingehuurd.
Het bureau heeft 22 recruiters en krijgt ongeveer 2.640 kandidaat-inzendingen per week binnen. Zo'n derde daarvan is een duplicaat van iemand die al in minimaal één van de drie systemen zit. Alleen het reconciliëren kostte elke recruiter ongeveer negentig minuten per dag, elke dag, zonder zicht op een einde. De opdracht was simpel om uit te spreken en niet simpel om te leveren: breng de drie systemen op één lijn, zonder ooit een cv automatisch af te wijzen dat een recruiter niet zelf heeft beoordeeld.
Hoe 2.640 inzendingen per week er in de praktijk uitzien
Het eerste dat je moet begrijpen: 'dubbele kandidaat' is geen enkel probleem. Het zijn minstens vier overlappende problemen, en elk vraagt om een andere oplossing.
- Dezelfde persoon, hetzelfde e-mailadres, twee records. In theorie triviaal, in de praktijk tienduizend edge cases (privé- versus zakelijk adres, agency-forwarded inboxen, gmail-puntjestrucs).
- Dezelfde persoon, ander e-mailadres, hetzelfde telefoonnummer. Standaard na een baanwissel.
- Dezelfde persoon, geen overlap in e-mail of telefoon, maar wel dezelfde naam, hetzelfde geboortejaar en dezelfde laatste werkgever. Komt veel voor als een kandidaat met jaren ertussen via een vacaturebank solliciteert.
- Verschillende mensen, dezelfde naam. Op de Nederlandse markt heel gewoon, veelvoorkomende achternamen stapelen snel op.
Voordat de agent er was, had het bureau twee dingen geprobeerd. Eerst een 'duplicate-cleanup-uurtje' op vrijdag, waarin elke recruiter zijn eigen desk moest opschonen. Dat hield het binnen drie weken niet meer vol, want niemand sluit dat uur af met een kleinere queue dan waarmee hij begon, en niemand heeft de politieke ruimte om het record van een collega als fout aan te merken. Daarna een externe dedup-leverancier die alleen tegen Bullhorn draaide, de andere twee systemen volledig negeerde, en daar in stilte €1.400 per maand voor incasseerde. Geen van beide aanpakken overleefde een desk met meerdere systemen.
De agent moest alle vier de duplicaatvormen aankunnen, zonder ooit stilletjes het verkeerde record te overschrijven. De ene harde regel, in de eerste scoping-meeting afgesproken en nooit losgelaten: geen enkele kandidaat zou ooit door het systeem zelf worden afgewezen, gearchiveerd of benaderd. De agent stelt voor. De mens beslist.
De reconciliation-key waar we op uitkwamen
Eerst hebben we e-mail plus telefoon gehasht. Dat miste de helft van de duplicaten. Daarna een fuzzy match op naam en geboortedatum. Dat leverde een vloedgolf aan false positives op, omdat veelvoorkomende Nederlandse achternamen alles platgooien. De versie die productie heeft overleefd is een samengestelde key, gescoord, met een threshold.
import re, unicodedata
import phonenumbers
def normalize_name(s: str) -> str:
s = unicodedata.normalize("NFD", s)
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
return re.sub(r"\s+", " ", s.lower().strip())
def canonical_email(s: str) -> str:
s = (s or "").strip().lower()
if s.endswith("@gmail.com"):
local, _, domain = s.partition("@")
local = local.split("+", 1)[0].replace(".", "")
s = f"{local}@{domain}"
return s
def to_e164(raw: str, region: str = "NL") -> str | None:
try:
n = phonenumbers.parse(raw, region)
if not phonenumbers.is_valid_number(n):
return None
return phonenumbers.format_number(
n, phonenumbers.PhoneNumberFormat.E164)
except Exception:
return None
def reconciliation_key(c) -> dict:
return {
"email": canonical_email(c.email),
"phone": to_e164(c.phone),
"name": normalize_name(c.full_name),
"yob": c.dob.year if c.dob else None,
}
De match-regel is bewust conservatief. Twee records scoren als harde match wanneer twee van (e-mail, telefoon, naam+yob) gelijk zijn. Alles wat zwakker is, gaat in de queue voor een mens en wordt nooit automatisch samengevoegd. We loggen bovendien elke near-miss met score, zodat we de threshold later op basis van echte productiedata kunnen bijschaven in plaats van op gevoel.
Aan de scoring-weights zit geen magie. Exacte match op e-mail: 1.0. Exacte match op telefoon na E.164-normalisatie: 1.0. Match op naam plus geboortejaar: 0.8. Alleen een fuzzy naam-match (Levenshtein-afstand van twee of minder op de genormaliseerde naam): 0.4. Sommeren tot een score per paar. Threshold voor een harde match is 1.6, voor queue-review 1.0, daaronder wordt het onderdrukt tenzij hetzelfde paar binnen dertig dagen opnieuw opduikt. Dat venster van dertig dagen vangt de kandidaat die in maart werd afgewezen en in mei onder een ander e-mailadres opnieuw solliciteert.
De vier-ogen-queue die we in elk write-path bouwen
De belangrijkste zin uit dit hele playbook: de agent schrijft nooit rechtstreeks naar één van de drie ATS-systemen. Hij schrijft naar een proposals-tabel. Een recruiter beoordeelt. Een tweede recruiter keurt goed. Pas dan voert de dispatcher de onderliggende API-call uit.
type ProposalAction =
| "merge_candidates"
| "update_status"
| "create_note"
| "tag_duplicate";
interface Proposal {
id: string;
system: "bullhorn" | "otys" | "carerix";
action: ProposalAction;
payload: Record<string, unknown>;
diff: { before: unknown; after: unknown };
confidence: number; // 0..1, from the matcher
reasoning: string; // the agent's own explanation
proposed_at: string; // ISO 8601
reviewed_by?: string; // recruiter id, first eyes
approved_by?: string; // recruiter id, second eyes
executed_at?: string;
}
Elke proposal bevat de exacte geserialiseerde API-call die uitgevoerd zou worden, een structurele diff van de before- en after-state, de confidence-score van de matcher, en een korte natural-language reasoning-string die de agent voor de menselijke reviewers schrijft. De queue-UI laat dat allemaal op één scherm zien, met de tweede beoordelaar geblokkeerd voor de recruiter die als eerste keek. Zijn de twee beoordelaars het oneens, dan blijft de proposal staan tot de teamlead de knoop doorhakt.
Bouw je in de EU een recruitment-automation en laat je die een kandidaat afwijzen, scoren, of zelfs stilzwijgend lager prioriteren zonder menselijke review, dan zit je in de scope van artikel 22 van de AVG over geautomatiseerde besluitvorming, en vanaf 2 augustus 2026 ook in de hoogrisico-verplichtingen van de EU AI Act. Of je behandelt de vier-ogen-queue als niet-onderhandelbaar, of je reserveert een jaar juridisch werk.
Carerix als oudste broer
De Carerix-installatie van het Tilburgse bureau is twaalf jaar oud. Het export-endpoint voor kandidaten is een SOAP-call die XML teruggeeft en weigert te pagineren. Op een rustige dag is de respons zo'n 7.000 records; tegen de productie-load-balancer loopt hij na zestig seconden uit zijn timeout. We hebben het opgelost zoals je elk oud API oplost dat geen respect voor je heeft: cursor op createdAt, het checkpoint tussen runs persisteren in Postgres, en backoff op een SOAP-fault waarin 'Service Unavailable' voorkomt.
def walk_carerix_candidates(checkpoint: datetime) -> Iterator[Candidate]:
cursor = checkpoint
while True:
try:
batch = carerix.candidates(created_after=cursor, limit=200)
except SoapFault as e:
if "Service Unavailable" in str(e):
time.sleep(30); continue
raise
if not batch:
return
for c in batch:
yield c
cursor = batch[-1].created_at
persist_checkpoint("carerix", cursor)
Bullhorn en Otys zijn vriendelijker buren. De REST API van Bullhorn gedraagt zich zoals een moderne REST API zich hoort te gedragen: paginated, JSON, rate-limited maar gedocumenteerd. Otys draait een SOAP-en-REST-hybride die zich netjes gedraagt zolang je de per-tenant token-cap respecteert. Het interessante werk zat nooit in de API-calls. Het zat in de reconciliation-key, de queue, en het rollback-beleid.
Het legacy SOAP-endpoint had nog één verrassing voor ons, die we in week twee vonden. De createdAt die in de cursor-respons terugkomt, is de allereerste creatie-timestamp van het record. Op een twaalf jaar oude installatie is dat soms 2014. Voor records die tijdens een eerdere Carerix-migratie in 2017 waren geïmporteerd, sprong de cursor bij elke herstart drie jaar terug, deed hij hetzelfde werk opnieuw, en at hij het rate-limit-budget voor de dag op. De fix was een tweede cursor op lastModifiedAt met een sliding window van twee dagen, plus een deduplicerende set in Redis die records die we in de huidige run al hadden verwerkt eruit gooit.
Drie weken shadow mode voordat er één write live ging
We hebben de agent in productie gezet met elk write-path uitgeschakeld. De eerste drie weken stelde de agent voor; er werd niets uitgevoerd. De queue-UI stond live, maar gemarkeerd als 'shadow mode', en de dispatcher weigerde te vuren. We logden elke proposal naast wat recruiters in diezelfde periode daadwerkelijk deden, en maten de overeenstemmingsgraad.
Op de eerste dag was de overeenstemming 71%. Het grootste deel van het gat zat in de naam-matcher, die te gretig was op veelvoorkomende Nederlandse achternamen. We hebben de threshold strakker gezet, de geboortejaar-eis toegevoegd wanneer alleen de namen matchten, en opnieuw gedraaid. Aan het einde van week drie zat het op 94%, en cruciaal: de overgebleven 6% verschillen waren bijna altijd gevallen waar de recruiter context had die de agent niet kon hebben (een telefoongesprek, een klantcontact, een LinkedIn-bericht). Toen hebben we de dispatcher aangezet met een cap van vijftig uitgevoerde proposals per dag, en die in twee weken stap voor stap opgevoerd.
Wat ons in shadow mode verraste was de asymmetrie. De agent had iets vaker gelijk dan de recruiters op de makkelijke gevallen, waar twee records hetzelfde e-mailadres en telefoonnummer deelden. Op de ambigue gevallen won het oordeel van de recruiter het elke keer van de score van de agent, en het verschil was niet klein. Door die asymmetrie besloot het team om menselijke goedkeuring permanent te houden in plaats van die na een vertrouwensperiode af te bouwen. We willen geen agent die gemiddeld gelijk heeft; we willen een agent die nooit eenzijdig ongelijk heeft.
Shadow mode is de goedkoopste verzekering die je ooit op een write-path-automation afsluit. Drie weken impactloos loggen geeft je het betrouwbaarheidsinterval waar een stuurgroep om blijft vragen en dat je niet kunt faken.
De kill switch en het rollback-beleid
Elke uitgevoerde proposal is omkeerbaar. De dispatcher bewaart de structurele diff van de before- en after-state naast de API-call die hij heeft verstuurd, en weet hoe hij elk van de vier ondersteunde acties moet omdraaien: merges kunnen worden gesplitst, statuswijzigingen teruggedraaid, notities soft-deleted, duplicate-tags verwijderd. De rollback is zelf een proposal in de queue die door dezelfde vier-ogen-review gaat, met één extra regel: de oorspronkelijke goedkeurder van een actie mag niet de rollback ervan goedkeuren. Andere ogen, by design.
Op één uitzondering na: de kill switch. Loopt de error-rate van de agent per dag tegen één enkel ATS boven de drie procent, dan pauzeert de dispatcher globaal, gaat er een Slack-alert naar de teamlead, en weigert hij verdere proposals te verzenden tot een mens vanuit de operations-console expliciet een unpause-commando draait. De threshold is één keer afgesteld, in week zes, nadat een Bullhorn-deploy een status-enum had veranderd en we in stilte achtendertig records verkeerd hadden weggeschreven voordat iemand het zag. Elke process-automation die productiedata aanraakt heeft een uitgang nodig; dit is de onze. Het bouwen van de kill switch vooraf kostte ongeveer twee engineering-dagen. De kosten van het niet hebben ervan op de ochtend dat we die achtendertig records vonden, waren onbetaalbaar geweest.
Wat we bewust niet hebben geautomatiseerd
De kortste paragraaf van het playbook en de belangrijkste. De agent stuurt geen berichten naar kandidaten. Hij zet een kandidaatstatus niet van 'in proces' naar 'afgewezen'. Hij past geen vacature aan. Hij schrijft geen score. Hij mailt geen klant. Alles hierboven stond op de oorspronkelijke wensenlijst. Alles hierboven viel van die lijst af zodra we een middag bij een recruiter aan tafel zaten en zagen wat die acties feitelijk vragen (oordeel, toon, een telefoongesprek, een screenshot van een cv met een koffievlek erop).
Wat overblijft voor de agent is het substraatwerk: deduplicatie, status-reconciliatie tussen systemen, voorgestelde merges, het proactief flaggen van records die er muf of tegenstrijdig uitzien, en de vier-ogen-proposals. Dat is meer dan genoeg. De 90 minuten per recruiter per dag aan reconciliatiewerk zit nu dichter tegen 12, en die 12 minuten gaan op aan het reviewen van de queue, niet aan duplicaten najagen over drie browser-tabs. Process automation die oordeel respecteert is automation die de juiste grens trekt, en die juiste grens ligt bijna altijd strakker dan de oorspronkelijke wensenlijst.
Eén praktische stap die je deze week kunt zetten
Draai je een multi-ATS- of multi-CRM-operatie, dan is het kleinste dat je vanmiddag kunt doen die audit die je teamlead al een jaar voor zich uitschuift. Open alle drie de systemen naast elkaar, pak vijftien willekeurige kandidaten uit de afgelopen kalendermaand, en traceer elk van hen in alle drie. Tel de duplicaten. Tel hoe lang het traceren kostte. Dat getal is je baseline, en je kunt niet verbeteren wat je niet hebt gemeten.
Toen we de process-automation-agent bouwden voor het Tilburgse bureau, zat de verrassing niet in het SOAP-API of de duplicaatdetectie. Het zat in de auditeerbaarheid van de vier-ogen-queue zelf: de Autoriteit Persoonsgegevens wilde elke proposal, elke reviewer en elke goedkeurder kunnen inzien, de volledige bewaartermijn lang. We hebben dat opgelost door de queue weg te schrijven naar een append-only Postgres-tabel met row-level signatures, plus een Grafana-view die de compliance officer op maandagochtend leest. Dat is het feitelijke product. Het reconciliëren is het makkelijke deel.
Kern
Laat de agent nooit een write-knop indrukken die een mens nog niet heeft gezien. De vier-ogen-queue is het hele product, geen feature aan de zijkant.
FAQ
Kan de agent kandidaten afwijzen als een recruiter een vaste regel goedkeurt?
Nee. Ook met zo'n regel loopt elke afwijzing nog steeds door de vier-ogen-queue en wordt hij uitgevoerd onder het account van de beoordelaar, nooit dat van de agent. De vaste regel verandert alleen de standaardwaarden op de proposal, niet wie hem goedkeurt.
Hoe lang duurde de bouw van begin tot eind?
Twaalf weken van kickoff tot de eerste uitgevoerde write. Drie van die weken zaten in shadow mode, waarin de agent elke actie wel voorstelde maar de dispatcher weigerde te vuren.
Wat gebeurt er als het SOAP-endpoint van Carerix uitvalt?
De dispatcher backt automatisch af en parkeert de write in de queue. Een nightly job probeert het opnieuw met het cursor-checkpoint. Writes naar Bullhorn en Otys gaan gewoon door, want elk systeem heeft zijn eigen dispatcher.
Valt dit ontwerp onder artikel 22 van de AVG?
Zolang een mens elke beslissing reviewt en goedkeurt, niet. De vier-ogen-queue is de human-in-the-loop-garantie die de agent buiten de scope van artikel 22 houdt, en buiten de hoogrisico-verplichtingen van de EU AI Act.
Wat als de twee beoordelaars het oneens zijn over een proposal?
Dan blijft hij in de queue staan tot de teamlead de knoop doorhakt. Onenigheden vormen minder dan 6% van alle proposals en gaan bijna altijd over context die de agent niet had kunnen zien, zoals een telefoongesprek met de kandidaat.