← Blog

Process automation

Teams ops agent for ATS: two-click candidate email approvals

A Tuesday afternoon in Antwerp: a recruiter drags a candidate to client interview scheduled, and the follow-up email that should fire never does. Here is the Teams agent we built to fix it.

Jacob Molkenboer· Founder · A Brand New Company· 8 Jun 2026· 8 min
Wooden card tray with two cream index cards stamped approved, green ribbon, brass paper clip, manila slip on ivory desk.

It is a Tuesday afternoon at a recruiting firm off the Meir in Antwerp. A senior consultant drags a candidate card from "phone screen done" to "client interview scheduled". Outlook stays empty. The candidate hears nothing for another two days, by which point a competing agency has already sent calendar invites and a brief.

This was the actual leak. Sixty consultants, a healthy ATS pipeline, and a stage-change-to-email gap that swallowed roughly one in five placements. The fix was not another reminder bot. The fix was a Microsoft Teams agent that drafts the email the moment the stage changes, then waits inside the recruiter's day-to-day chat window for two clicks: Send, or Edit and send.

Here is exactly how we wired it.

The shape of the system

Four moving parts, nothing more:

  • A webhook from the ATS (Recruitee in this case) that fires on every candidate stage change.
  • A small worker that fetches the candidate context and drafts the email body.
  • A Microsoft Teams bot that posts an Adaptive Card into the responsible consultant's 1:1 chat with the bot.
  • An action handler that listens for the card's button taps and sends through Outlook via Microsoft Graph, or opens an inline editor.

No queue server, no Kafka, no in-house model hosting. The worker is a single Azure Function. The bot is a standard Bot Framework app, registered through the Teams developer portal. Total moving-part count: lower than the number of consultants on the floor.

The reason this works is that recruiters already live in Teams all day, between client calls, internal Q&A, and the obligatory dog-photo channel. An Adaptive Card that lands in their existing chat thread is not a new tool. It is part of the chat client they already use, with two buttons on it.

The ATS webhook

Recruitee exposes a Webhooks API. We subscribe to candidate_moved events and point them at a Function URL with a shared secret in the query string. The same pattern works for Bullhorn, Teamtailor, Workable, and Loxo. They all expose stage-change events under slightly different names; pick the one your firm already pays for.

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
    }
  }'

The payload includes the candidate id, the source and target stage ids, the assigned consultant id, and the actor who moved the card. Everything else (job title, client name, candidate language, last note) is one extra API call away. We fetch that synchronously in the worker, because we want the draft in the recruiter's Teams chat within ten seconds of the drag-and-drop. Async pipelines that take a minute feel broken inside a chat client.

Building the draft

The worker reads the candidate record, the related job, and the last few timeline events. Then it asks the model to draft an email tuned to the specific stage transition. The prompt is short and boring on purpose. Long prompts encourage long output, and long output gets edited or skipped.

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 });
}

Three notes from the trenches.

First, we cache the draft against a candidate_id + target_stage key for ten minutes, so an "oops" double-move does not burn two model calls. Second, we never let the model output the subject line. The subject is templated from the stage so that Outlook threading stays clean and the recruiter can search their sent items later. Third, we hard-code an upper word count. If the draft comes back over 110 words, we re-prompt with a stricter cap. Brevity beats personalisation here, because the recruiter is going to skim before approving.

The two-click Adaptive Card

The card is the entire UX. If it is not readable in three seconds and tappable in two, the system fails and the recruiter goes back to Outlook. Here is the shape we landed on, using 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}" }
    }
  ]
}

Two buttons. Send writes through Graph as the consultant. Edit and send opens a task module with the draft pre-filled, the recruiter tweaks it, hits send inside the task module, and the same Graph call fires.

No third "Skip" button. Silent dropping of the card is what got the firm into this mess in the first place. If a recruiter genuinely wants to skip the email, they close the card and the system logs that as an explicit decision after a thirty-minute timeout. That log is the data we use to retune the stage-to-template mapping.

Handling the click

The bot receives an Invoke activity. The handler is roughly thirty lines:

// 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
  }
}

The Graph call uses application permissions plus an Exchange impersonation grant scoped to the recruiter's mail domain. Microsoft's sendMail endpoint then dispatches the message as the consultant, so the candidate sees a normal email from the person they recognise, with the consultant's regular signature and reply-to address. To the candidate this is invisible. They cannot tell an agent drafted it, and that is the point.

The audit trail back into the ATS is the piece consultants did not know they wanted. Every Send writes a timeline note on the candidate ("stage email sent via Teams agent at 14:03"). Every Edit logs the delta between the original draft and the final text. Six weeks after launch the team lead used that delta log to coach two junior consultants on tone, which is a use of automation data we had not planned for.

What we instrumented

You do not ship a writing agent without metrics. Three counters and one histogram cover the floor:

  • send_clicks_total by stage and by consultant.
  • edit_then_send_total by stage.
  • skip_total for cards left to time out.
  • draft_to_click_seconds, bucketed at thirty seconds.

The edit-rate per stage goes back to the partners as a weekly Teams message. If the rate creeps above 35% for a given stage, the template is wrong and the prompt needs revision. That single dashboard is what stops the system rotting six months in. We also feed the edit deltas back as few-shot examples on a monthly cadence, never in real time. Real-time prompt rewriting is a fast way to drift the voice of the firm in a direction nobody approved.

The gotchas we hit

Warning

Adaptive Card actions outside a 1:1 bot chat behave differently. In a shared channel post the Send button can fire on someone else's behalf if they tap it first. We restrict draft cards to the 1:1 chat between the bot and the consultant who moved the card. Anything else is a permission tarpit.

Three other lessons that cost us roughly a day each:

  • Language detection lives on the candidate, not the consultant. Half the firm's recruiters work bilingually in Dutch and French. We pull the candidate's CV language from the ATS field and pass it as a hard constraint to the draft prompt. Without that, the model defaults to English and the recruiter rewrites from scratch.
  • Stage debounce is mandatory. A consultant who realises they dragged a card into the wrong column has about five seconds to drag it back before they want anything to happen. We debounce on candidate_moved for twenty seconds before drafting. Cheap to implement, saves a lot of "please ignore the last email" follow-ups.
  • Out-of-office routing matters in a sixty-person agency. If the assigned consultant is OOO, the card goes to their named backup, not into the void. We sync from the Outlook automatic-replies endpoint twice a day to keep that mapping current.

What changed for the firm

Two months after going live: median stage-to-first-touch latency dropped from 31 hours to 14 minutes during business hours. The firm did not add headcount, did not change ATS, and did not move off Teams. Consultants still write the careful emails themselves. The agent makes sure the easy ones get out the door before lunch.

There is a quieter second-order effect that mattered to the partners. Because the agent posts each draft into the consultant's own 1:1 with the bot, that chat becomes a daily ledger of every stage decision they made. At the end of a busy day a consultant can scroll back through ten cards and see exactly which candidate got which signal, in which language, with which tone. The COO told us that visibility was worth more than the latency win.

If you want to copy this pattern in your own shop, the smallest first step is mapping which ATS stages should trigger which template, and whose calendar owns the response when the assigned consultant is out. Forty-five minutes on a whiteboard with two people. Build nothing until that mapping is honest. The agent is the easy part. The template-to-stage map is where your firm's actual recruiting style lives.

When we built this for the Antwerp firm, the thing that bit us late was Outlook's "external sender" warning banner clobbering the agent's send-as identity for first-contact candidate addresses. We solved it by warming each consultant's mailbox with a Graph-side allow list and a one-off CSV import from the ATS contacts table. If you want the same shape of AI agents wired into your existing ATS and Teams stack, that is the kind of detail we sweat.

Key takeaway

Drafts beat reminders. Put a sendable email in the recruiter's chat the instant the stage changes, not three hours later.

FAQ

How long did this take to build end to end?

Roughly three weeks for a working pilot with one consultant, then another two weeks to roll out to the floor with backup routing and metrics. Most of that time was stage-template mapping, not code.

Does this only work with Recruitee?

No. Bullhorn, Teamtailor, Workable, and Loxo all expose equivalent stage-change webhooks. The worker code changes about thirty lines per ATS. The Teams side stays identical.

What happens if the candidate replies?

The reply lands in the consultant's Outlook as a normal email thread. The agent does not auto-respond. We tested auto-triage and recruiters preferred to keep replies fully human.

How do you handle GDPR for the AI-drafted text?

Data sent to the model is restricted to first name, job title, client name, stage names, and language. No CV body, no contact details. Drafts drop from cache after ten minutes.

process automationai agentsintegrationsworkflowemail automationcase study

Building something?

Start a project