Process automation
Teams ops-agent in je ATS: kandidaat-mails in 2 klikken
Dinsdagmiddag in Antwerpen: een recruiter sleept een kandidaat naar 'gesprek bij klant gepland'. De opvolg-mail die had moeten vertrekken, vertrekt nooit. Dit is de Teams-agent die dat dichttimmert.

Het is dinsdagmiddag bij een recruitmentbureau net naast de Meir in Antwerpen. Een senior consultant sleept een kandidaatkaart van 'telefoongesprek gedaan' naar 'gesprek bij klant gepland'. Outlook blijft leeg. De kandidaat hoort twee dagen niets, en tegen die tijd heeft een concurrerend bureau de agenda-uitnodigingen en een briefing al verstuurd.
Dit was het echte lek. Zestig consultants, een gezonde ATS-pipeline en een gat tussen stagewissel en mail dat ongeveer één op de vijf plaatsingen opslokte. De oplossing was niet alweer een reminder-bot. De oplossing was een Microsoft Teams-agent die de mail opstelt op het moment dat de stage verandert, en vervolgens in het chatvenster van de recruiter wacht op twee klikken: Versturen, of Bewerken en versturen.
Zo hebben we het precies opgebouwd.
De vorm van het systeem
Vier bewegende delen, meer niet:
- Een webhook vanuit het ATS (in dit geval Recruitee) die afgaat bij elke kandidaat-stagewissel.
- Een kleine worker die de kandidaatcontext ophaalt en de mailtekst opstelt.
- Een Microsoft Teams-bot die een Adaptive Card post in de 1-op-1-chat van de verantwoordelijke consultant met de bot.
- Een action handler die luistert naar de buttonklikken op de card en via Microsoft Graph verstuurt vanuit Outlook, of een inline editor opent.
Geen queue-server, geen Kafka, geen eigen model-hosting. De worker is één Azure Function. De bot is een standaard Bot Framework-app, geregistreerd via de Teams developer portal. Totaal aantal bewegende delen: minder dan het aantal consultants op de vloer.
Dit werkt omdat recruiters de hele dag al in Teams zitten, tussen klantgesprekken, interne Q&A en het verplichte hondenfotokanaal door. Een Adaptive Card die in hun bestaande chat-thread landt, is geen nieuwe tool. Het is onderdeel van de chat-client die ze toch al gebruiken, met twee buttons erop.
De ATS-webhook
Recruitee heeft een Webhooks API. We abonneren op candidate_moved-events en sturen ze naar een Function-URL met een shared secret in de query string. Hetzelfde patroon werkt voor Bullhorn, Teamtailor, Workable en Loxo. Ze stellen allemaal stagewissel-events bloot onder iets andere namen; pak degene waar je bureau toch al voor betaalt.
curl -X POST https://api.recruitee.com/c/{company_id}/webhooks \
-H "Authorization: Bearer $RECRUITEE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"url": "https://ats-agent.azurewebsites.net/api/stage?key=...",
"events": ["candidate_moved"],
"enabled": true
}
}'
De payload bevat het candidate-id, de bron- en doelstage-ids, het id van de toegewezen consultant en wie de kaart heeft verplaatst. De rest (functietitel, klantnaam, taal van de kandidaat, laatste notitie) is één extra API-call verderop. Dat halen we synchroon op in de worker, want we willen de draft binnen tien seconden na het slepen in de Teams-chat van de recruiter hebben staan. Async pipelines die een minuut duren voelen kapot in een chat-client.
De draft opbouwen
De worker leest het kandidaatrecord, de bijbehorende vacature en de laatste paar timeline-events. Daarna vraagt hij het model om een mail op te stellen die past bij de specifieke stagewissel. De prompt is kort en saai, met opzet. Lange prompts moedigen lange output aan, en lange output wordt herschreven of overgeslagen.
type StageMove = {
candidate: { firstName: string; lastName: string; email: string; lang: 'nl' | 'fr' | 'en' };
job: { title: string; clientName: string };
from: string;
to: string;
consultant: { firstName: string; signature: string };
};
const systemPrompt = `
You write follow-up emails for a Belgian recruiter.
Match the candidate's language (nl/fr/en). Maximum 90 words.
No marketing tone. No emoji. Sign off with the consultant's first name only.
The body MUST end with ONE concrete next step:
a date proposal, a document request, or a single question.
`;
async function draft(move: StageMove) {
const user = `Stage move: ${move.from} -> ${move.to}.
Candidate: ${move.candidate.firstName}. Job: ${move.job.title} at ${move.job.clientName}.
Language: ${move.candidate.lang}.
Consultant: ${move.consultant.firstName}.`;
return await llm({ system: systemPrompt, user });
}
Drie lessen uit de praktijk.
Eén: we cachen de draft tien minuten op een sleutel candidate_id + target_stage, zodat een 'oeps'-dubbele verplaatsing geen twee model-calls kost. Twee: we laten het model nooit de onderwerpregel produceren. Het onderwerp komt uit een template per stage, zodat de Outlook-threading netjes blijft en de recruiter zijn verzonden items later kan doorzoeken. Drie: we hardcoderen een maximaal aantal woorden. Komt de draft terug boven de 110 woorden, dan promtpen we opnieuw met een strakkere limiet. Kort wint hier van persoonlijk, want de recruiter scant voor hij goedkeurt.
De Adaptive Card met twee klikken
De card is de hele UX. Is hij niet in drie seconden te lezen en in twee seconden te tappen, dan faalt het systeem en gaat de recruiter terug naar Outlook. Dit is de vorm waar we op uitkwamen, met Adaptive Cards v1.5:
{
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Tom Geens · Senior DevOps · Acme Manufacturing",
"weight": "Bolder",
"wrap": true
},
{
"type": "TextBlock",
"text": "phone screen done → client interview scheduled",
"isSubtle": true,
"spacing": "None"
},
{
"type": "TextBlock",
"text": "Subject: Volgende stap, Acme Manufacturing",
"wrap": true,
"spacing": "Medium"
},
{ "type": "TextBlock", "text": "${draftBody}", "wrap": true }
],
"actions": [
{
"type": "Action.Submit",
"title": "Send",
"data": { "verb": "send", "draftId": "${draftId}" },
"style": "positive"
},
{
"type": "Action.Submit",
"title": "Edit and send",
"data": { "verb": "edit", "draftId": "${draftId}" }
}
]
}
Twee buttons. Versturen schrijft via Graph onder de naam van de consultant. Bewerken en versturen opent een task module met de draft alvast ingevuld, de recruiter sleutelt eraan, klikt versturen binnen die task module, en dezelfde Graph-call vuurt af.
Geen derde 'Overslaan'-knop. Het stil laten vallen van de card was precies wat het bureau in deze ellende had gebracht. Wil een recruiter de mail echt overslaan, dan sluit hij de card en logt het systeem dat na een time-out van dertig minuten als een expliciete beslissing. Die log gebruiken we om de stage-naar-template-mapping bij te stellen.
De klik afhandelen
De bot ontvangt een Invoke-activity. De handler is ruwweg dertig regels:
// Teams bot activity handler
async onAdaptiveCardInvoke(context, invokeValue) {
const { verb, draftId } = invokeValue.action.data;
const draft = await store.get(draftId);
if (verb === 'send') {
await graphSendMail({
onBehalfOf: context.activity.from.aadObjectId,
to: draft.candidate.email,
subject: draft.subject,
html: draft.bodyHtml,
});
await ats.note(draft.candidateId, 'Stage email sent via Teams agent');
return cardResult('Sent. Stage note added to Recruitee.');
}
if (verb === 'edit') {
return taskModuleWith(draft); // editor pre-filled with the draft
}
}
De Graph-call gebruikt application permissions plus een Exchange-impersonatie die gescoped is op het maildomein van de recruiter. Het sendMail-endpoint van Microsoft verstuurt het bericht vervolgens namens de consultant, zodat de kandidaat een gewone mail ziet van de persoon die hij kent, met de normale handtekening en het normale reply-to-adres. Voor de kandidaat is dit onzichtbaar. Hij kan niet zien dat een agent de mail heeft opgesteld, en dat is precies de bedoeling.
De audit-trail terug naar het ATS is het stukje waarvan consultants niet wisten dat ze het wilden. Elke Versturen schrijft een timeline-notitie op de kandidaat ('stage-mail verstuurd via Teams-agent om 14:03'). Elke Bewerken logt het verschil tussen de oorspronkelijke draft en de uiteindelijke tekst. Zes weken na livegang gebruikte de teamlead die delta-log om twee junior consultants te coachen op toon. Een gebruik van automatiseringsdata waar we niet op hadden gerekend.
Wat we hebben geïnstrumenteerd
Je shipt geen schrijvende agent zonder metrics. Drie tellers en één histogram dekken de hele vloer:
send_clicks_totalper stage en per consultant.edit_then_send_totalper stage.skip_totalvoor cards die zijn uitge-time-out.draft_to_click_seconds, in buckets van dertig seconden.
De edit-ratio per stage gaat wekelijks terug naar de partners als Teams-bericht. Komt die ratio voor een bepaalde stage boven de 35%, dan klopt de template niet en moet de prompt herzien worden. Dat ene dashboard zorgt ervoor dat het systeem na zes maanden niet wegrot. We voeden de edit-deltas ook terug als few-shot voorbeelden, maar op maandelijkse cadans, nooit realtime. Realtime prompts herschrijven is een snelle manier om de stem van het bureau te laten driften in een richting waar niemand toestemming voor heeft gegeven.
De valkuilen waar we tegenaan liepen
Adaptive Card-acties gedragen zich buiten een 1-op-1-bot-chat anders. In een gedeeld kanaal kan de Versturen-knop namens iemand anders afgaan als die het eerst tapt. We beperken draft-cards tot de 1-op-1-chat tussen de bot en de consultant die de kaart heeft verplaatst. Alles daarbuiten is een permissie-moeras.
Drie andere lessen die ons elk ongeveer een dag kostten:
- Taaldetectie hoort bij de kandidaat, niet bij de consultant. De helft van de recruiters van het bureau werkt tweetalig Nederlands en Frans. We halen de CV-taal van de kandidaat uit het ATS-veld en geven die mee als harde restrictie aan de draft-prompt. Zonder dat valt het model terug op Engels en herschrijft de recruiter de mail van nul af aan.
- Stage-debounce is verplicht. Een consultant die zich realiseert dat hij een kaart in de verkeerde kolom heeft gesleept, heeft ongeveer vijf seconden om hem terug te slepen voordat er iets mag gebeuren. We debouncen
candidate_movedtwintig seconden voordat we draften. Goedkoop te bouwen, scheelt veel 'negeer mijn vorige mail'-opvolging. - Out-of-office-routering is belangrijk in een agency van zestig man. Is de toegewezen consultant OOO, dan gaat de card naar zijn aangewezen backup, niet het luchtledige in. We syncen die mapping twee keer per dag vanuit het automatic-replies-endpoint van Outlook.
Wat er voor het bureau veranderde
Twee maanden na livegang: de mediane latency van stage tot eerste contact daalde van 31 uur naar 14 minuten binnen kantoortijd. Het bureau heeft geen mensen extra aangenomen, geen ATS gewisseld en is niet weg van Teams. Consultants schrijven de zorgvuldige mails nog steeds zelf. De agent zorgt ervoor dat de makkelijke mails de deur uit zijn vóór de lunch.
Er is een rustiger tweede-orde-effect dat de partners belangrijk vonden. Doordat de agent elke draft post in de 1-op-1-chat van de consultant met de bot, wordt die chat een dagelijks logboek van elke stagebeslissing die hij neemt. Aan het eind van een drukke dag kan een consultant tien cards teruglezen en precies zien welke kandidaat welk signaal kreeg, in welke taal, met welke toon. De COO vertelde ons dat die zichtbaarheid meer waard was dan de tijdwinst.
Wil je dit patroon kopiëren binnen je eigen bureau, dan is de kleinste eerste stap in kaart brengen welke ATS-stages welke template moeten triggeren, en wiens agenda het antwoord eigenaar is als de toegewezen consultant niet beschikbaar is. Vijfenveertig minuten op een whiteboard met twee mensen. Bouw niets totdat die mapping eerlijk is. De agent is het makkelijke deel. In de template-naar-stage-mapping zit de werkelijke recruitingstijl van je bureau.
Toen we dit bouwden voor het bureau in Antwerpen, was de laatste valkuil de 'externe afzender'-waarschuwingsbanner van Outlook, die de send-as-identiteit van de agent overschreef bij eerste-contact-kandidaten. We hebben het opgelost door de mailbox van elke consultant op te warmen met een allow-list aan Graph-zijde en een eenmalige CSV-import uit de contactentabel van het ATS. Wil je dezelfde vorm van AI-agents gekoppeld aan je bestaande ATS- en Teams-stack, dan is dat het soort detail waar wij in zweten.
Kern
Drafts winnen het van reminders. Zet een verstuurbare mail in de chat van de recruiter op het moment dat de stage wisselt, niet drie uur later.
FAQ
Hoe lang duurde dit om end-to-end te bouwen?
Ruwweg drie weken voor een werkende pilot met één consultant, en daarna nog twee weken om hem uit te rollen op de vloer met backup-routering en metrics. Het meeste van die tijd ging zitten in de stage-template-mapping, niet in de code.
Werkt dit alleen met Recruitee?
Nee. Bullhorn, Teamtailor, Workable en Loxo bieden allemaal vergelijkbare stagewissel-webhooks. De worker-code verandert ongeveer dertig regels per ATS. De Teams-kant blijft identiek.
Wat gebeurt er als de kandidaat antwoordt?
Het antwoord landt als een gewone mail-thread in de Outlook van de consultant. De agent reageert niet automatisch. We hebben auto-triage getest en recruiters wilden antwoorden liever volledig menselijk houden.
Hoe ga je om met de GDPR voor de door AI opgestelde tekst?
De data die naar het model gaat is beperkt tot voornaam, functietitel, klantnaam, stage-namen en taal. Geen CV-tekst, geen contactgegevens. Drafts vallen na tien minuten uit de cache.