← Blog

Email automation

Bullhorn-email-agent: Gmail-replies koppelen aan je ATS

Een Amsterdams recruitmentbureau verwerkt 84 kandidaat-replies per dag via één inbox. Dit is de exacte wiring waarmee we ze threaden, taggen en pre-draften in Bullhorn.

Jacob Molkenboer· Oprichter · A Brand New Company· 29 okt 2024· 9 min
Crème envelop met donkergroene lakzegel op groen leren onderlegger, groen lint, koperen clip, gevouwen carbonstrook.

Het is dinsdag 19:14 in Amsterdam-Zuid. Een recruiter bij een bureau van drieëndertig man heeft 84 ongelezen kandidaat-replies, Bullhorn open op het linkermonitor, Gmail op het rechter, en een spreadsheet waar ze kandidaatnamen in plakt om ze in het ATS terug te vinden. De reply die het zwaarst weegt komt van een Java-engineer die ze gisteren shortlistte. Hij ligt begraven onder drie out-of-office-bounces, een salarisvraag en een thread waarin de kandidaat per ongeluk zijn vrouw cc'de. Ze werkt door tot 21:00. Maandag deed ze hetzelfde.

Dit is de post over hoe we een email-agent in Gmail koppelden aan Bullhorn zodat de dinsdagavond-queue ophield te bestaan. Geen slide-deck-overzicht. De daadwerkelijke bewegende delen, de daadwerkelijke gotchas, en de code waar de code het interessante stuk is.

Wat er eigenlijk traag was

Het instinct, als een recruitmentbureau zegt dat email ze opvreet, is om volume te veronderstellen. Het volume was prima. 84 replies per dag voor het hele team is een normaal getal. Wat ze opvrat waren vier onzichtbare kosten.

Ten eerste vereiste elke reply een context-switch terug naar Bullhorn om het kandidaatrecord te vinden. Ten tweede was de threading kapot in ruwweg 20% van de gevallen omdat kandidaten antwoordden vanaf een ander adres dan dat in Bullhorn stond. Ten derde schreven recruiters de hele dag dezelfde vijf antwoorden (vragen om een CV-update, beschikbaarheid bevestigen, beleefd afwijzen). Ten vierde had het bureau geen idee welke threads warm waren omdat er niets getagd was.

Geen van die problemen is AI. Drie zijn loodgieterswerk. Eén, de standaardantwoorden, is waar een klein model zijn geld verdient. We begonnen bij het loodgieterswerk.

Push notifications van Gmail, geen polling

Het eerste wat elke blogpost over Gmail-automatisering verkeerd doet, is polling. Je polt Gmail niet. Je zet push notifications op via Cloud Pub/Sub en laat Gmail history-events naar je pushen. Pollen verbrandt quota, loopt minuten achter, en racy gebruikers klikken sneller dan je loop.

// Register the watch once per user. Pub/Sub does the rest.
await gmail.users.watch({
  userId: 'recruiter@firm.nl',
  requestBody: {
    topicName: 'projects/abn-ats-bridge/topics/gmail-inbox',
    labelIds: ['INBOX'],
    labelFilterAction: 'include',
  },
})

De watch geeft een historyId terug. Bewaar die per recruiter. Als Pub/Sub afgaat, haal je users.history.list op vanaf dat punt, krijg je de message-IDs die zijn toegevoegd, en verwerk je ze. De watch verloopt na zeven dagen. Vernieuw 'm elke vier dagen. Zet een alert als de volgende vernieuwing meer dan vijf dagen weg is, want Pub/Sub-renewal-fouten zijn stil.

Waarschuwing

De Gmail-watch-response geeft je een expiration in milliseconden sinds epoch, niet in seconden. Behandel 1717891200000 als een datum, niet als een timestamp uit 1970. We zijn er een zondag aan kwijt geweest.

De kandidaat matchen in Bullhorn

De Bullhorn REST API is prima zodra je accepteert dat authenticatie een driestaps-OAuth-ritueel is en de access token tien minuten leeft. Wikkel het in een refresh-on-401-client en denk er niet meer aan.

Een reply matchen aan een kandidaat heeft twee paden. Email is het voor de hand liggende. Bullhorn-kandidaten dragen tot drie e-mailvelden, dus een sender-match moet ze alle drie checken.

async function findCandidate(fromEmail) {
  const q = `email:${fromEmail} OR email2:${fromEmail} OR email3:${fromEmail}`
  const res = await bh.get('/search/Candidate', {
    params: {
      query: q,
      fields: 'id,firstName,lastName,status,owner',
      count: 5,
    },
  })
  return res.data?.[0] ?? null
}

Voor de 20% waarbij de kandidaat antwoordt vanaf een persoonlijke Gmail (de alias die je niet kreeg) is de fallback de originele uitgaande thread. Elke recruiter-mail uit Bullhorn of Gmail draagt een Message-ID. Als de inkomende mail een In-Reply-To heeft die wijst naar iets dat wij verstuurden, is de kandidaat degene aan wie we het laatst schreven op die thread. Dat dekt nog eens 18%. De resterende 2% wordt geflagd voor menselijke triage met een suggestie op basis van handtekening-parsing.

Threading, het deel dat iedereen verprutst

Gmail threadt niet op subject. Het threadt op de RFC 5322 reference-chain: Message-ID, In-Reply-To en References. Als je een draft verstuurt en de References-header vergeet, koppelt Gmail de reply visueel los, ook al is het subject identiek. Bullhorn doet zijn eigen versie hiervan met interne Note-IDs.

// What a properly threaded reply needs in its raw RFC822
Message-ID: <7f1a0b3c@firm.nl>
In-Reply-To: <9c2e4a11@gmail.com>
References: <a18b27@firm.nl> <9c2e4a11@gmail.com>
Subject: Re: Java backend role at [client]

De agent houdt een kleine lookup-tabel bij, gekeyd op Message-ID, die wijst naar de Bullhorn Note-ID. Als er een reply binnenkomt, hangen we de tekst van de kandidaat aan die note in plaats van een nieuwe aan te maken. De recruiter opent het kandidaatrecord en ziet het hele gesprek chronologisch, zonder Bullhorn te verlaten.

Een kleine classifier verdient zijn geld

Nu het model. Geen model. Een kleine classifier. Reply-intentie bij een recruitmentbureau is een gesloten set van zes categorieën: interested, not interested, scheduling, out of office, salarisvraag, recruiter-handoff. We sturen de e-mailtekst naar een klein snel model met één classificatie-instructie en strikte JSON-output. Geen reasoning mode, geen chain of thought, geen creatief schrijven. De classifier kost minder dan een tiende cent per reply en draait in zo'n 600 milliseconden.

{
  "intent": "scheduling",
  "confidence": 0.92,
  "extracted": {
    "proposed_times": ["2026-06-10T14:00+02:00", "2026-06-11T10:00+02:00"],
    "preferred_channel": "phone"
  }
}

De intent wordt een Bullhorn-tag op het kandidaatrecord. Recruiters kunnen nu in één klik filteren op elke interested Java-kandidaat die deze week antwoordde. Dat filter bestond niet voordat de agent er was.

Een interessant terzijde: of agents.md-bestanden coding-agents echt helpen, wordt deze week uitgevochten op Hacker News. Wij weten het ook niet zeker, maar de repo voor dit project heeft er evengoed eentje. Hij is kort, hij documenteert waar de kandidaat-matching-logica zit, en als we hier over drie maanden invallen om iets te fixen, hoeft noch de mens noch de volgende agent de structuur opnieuw uit te puzzelen.

De twee-klik-draft

Voor de categorieën waar het recruiter-antwoord voorspelbaar is (ongeveer 70% van de replies) genereert de agent een draft en bewaart die in Gmail. De recruiter opent de thread (klik één), leest de draft, past 'm aan indien nodig, en drukt op Verzenden (klik twee). Geen tab-wissel, geen kopiëren-plakken, geen omweg naar Bullhorn. De note in Bullhorn wordt bijgewerkt zodra de draft verstuurd is, via een Gmail-push-event op het SENT-label.

// Save the draft on the recruiter's behalf. Gmail shows it inline.
const raw = buildRfc822({
  to: candidateEmail,
  from: recruiterEmail,
  subject: `Re: ${originalSubject}`,
  inReplyTo: originalMessageId,
  references: [...originalReferences, originalMessageId],
  body: draftedBody,
})

await gmail.users.drafts.create({
  userId: recruiterEmail,
  requestBody: {
    message: {
      threadId: gmailThreadId,
      raw: Buffer.from(raw).toString('base64url'),
    },
  },
})

De draft-tekst komt uit een template per intent en wordt daarna gepersonaliseerd. In week één probeerden we volledig open generatie. De recruiters haatten het. De drafts waren te lang, te formeel, en vol "Ik hoop dat deze e-mail je goed bereikt." Overschakelen op slot-filled templates met één persoonlijke zin per draft bracht de edit-tijd per reply terug van 40 seconden naar 6 seconden in onze timing-logs.

Kernpunt

De winst in recruiter-emailautomatisering zit niet in beter schrijven. Hij zit in betere routering, betere threading, en een draft die de recruiter aanpast, niet goedkeurt.

Wat we in productie hebben betaald

Vijf gotchas die ons tijd kostten, voor het geval je iets vergelijkbaars bouwt.

Race conditions bij Bullhorn-token-refresh. De tien-minuten-token plus agressieve parallelisme betekent dat twee requests beide een 401 kunnen zien en beide proberen te refreshen, waarmee ze elkaar invalideren. Wikkel de refresh in een mutex per recruiter.

Out-of-office-floods. Eén weekend out-of-office-bounces van een outreach naar 600 kandidaten levert je 600 nutteloze notes op als je het laat gebeuren. De classifier vangt ze, maar de goedkope pre-filter is Auto-Submitted: auto-replied in de RFC-headers. Filter die weg voordat ze het model raken.

Reply-All en BCC-drift. Kandidaten loopen collega's, partners en soms hun volgende sollicitatiegesprek erin. De agent moet beslissen welke adressen volgende keer op de thread horen. Wij defaulten op de originele to/cc-set en vereisen dat de recruiter elk nieuw adres expliciet toevoegt. Dat heeft op dag 30 één onbedoelde disclosure voorkomen.

AVG-conforme logging. De classifier ziet kandidaat-e-mails. Dat zijn persoonsgegevens. Wij loggen de intent en de confidence, niet de ruwe tekst, en we laten kandidaten verwijdering aanvragen via een route die de recruiter nooit ziet. Nederlandse bedrijven worden hierop geaudit; sla het niet over.

Pub/Sub-renewal. Al genoemd, waard om te herhalen: stille fout, wekelijkse cadans, zet een externe monitor.

Wat de recruiter nu op een dinsdag ziet

Om 17:00 op een normale dag toont de inbox 30 tot 60 kandidaat-replies. Ongeveer 42 zijn al getagd, gethread naar het juiste Bullhorn-record, en pre-drafted. De recruiter opent elke thread, leest, en verzendt of past aan. De acht of zo die de classifier als low-confidence flagt, krijgen menselijke triage. Om 18:00 is ze klaar. Het spreadsheet met geplakte namen ligt in de prullenbak.

Toen we dit bouwden voor het Amsterdamse recruitmentbureau brak Pub/Sub-watch-renewal als eerste, precies zoals hierboven gewaarschuwd. De fix kostte een uur. Wat als tweede brak, was het vertrouwen van de recruiters in de drafts. We moesten de open generatie weggooien en opnieuw bouwen op templates voordat het team daadwerkelijk op Verzenden zou klikken. Beide zijn het soort fix dat je alleen in productie vindt, en daarom bouwen we elke AI-agent nu met een live-shadow-fase van een week voordat er een draft naar buiten gaat.

Wil je weten of dit voor jouw eigen inbox iets oplevert: grep deze week je verzonden map voor de vijf antwoorden die je het vaakst schrijft. Dat is het oppervlak dat je agent als eerste moet dekken.

Kern

De winst in recruiter-emailautomatisering zit niet in beter schrijven. Hij zit in betere routering, betere threading, en een draft die de recruiter aanpast, niet goedkeurt.

FAQ

Waarom niet de ingebouwde e-mailintegratie van Bullhorn gebruiken?

Bullhorn-mail is prima voor versturen en basislogging. Het classificeert geen intent, het schrijft geen drafts, en het threadt niet over alias-adressen heen. De Gmail-agent doet alle drie bovenop Bullhorn, niet in plaats van.

Verstuurt de agent mails zonder goedkeuring van de recruiter?

Nee. Elke uitgaande reply is een draft die de recruiter opent, controleert en handmatig verzendt. De agent spreekt nooit namens het bureau. Die ene regel zorgde ervoor dat recruiters 'm vertrouwden.

Hoe lang duurde het bouwen?

Drie weken bouwen, één week live-shadow naast de recruiters, één week templates herzien op basis van wat ze daadwerkelijk aanpasten. Vijf weken van kickoff tot het volledig vervangen van de handmatige queue.

Wat gebeurt er als Gmail's watch verloopt?

Een cron vernieuwt 'm elke vier dagen. Mislukt de vernieuwing drie keer op rij, dan krijgt het team een Slack-ping en valt de agent terug op een history-poll van 60 seconden tot de renewal lukt. Er gaan geen replies verloren.

email automationai agentsintegrationsworkflowcase studyoperations

Iets bouwen?

Start een project