43 Threads, 9 Ghosts Predicted, 87¢: Sentiment-Drift Agent

Abstract tech illustration: 43 Threads, 9 Ghosts Predicted, 87¢: Sentiment-Drift Agent

Nine of eleven flagged candidates ghosted the offer within ten days. The agent flagged all nine before they went silent. Total OpenAI spend across 43 threads: 87 cents. If you run a small agency and keep losing finalists between the verbal yes and the signed contract, this is the signal your ATS is structurally blind to.

Why ATS agents miss the ghosting signal

Every ATS agent — monday, Greenhouse, Ashby, Workable — reasons over the platform's data model. Stage moved from Interview to Offer. Candidate tagged hot. Contract sent 3 days ago. That's structured state, and platform agents automate it well. What they never see is the actual Gmail thread where the hiring manager and candidate are trading replies. That thread is where intent leaks first.

I pulled six months of finalist threads from a six-person marketing agency that hired me last quarter — 43 complete conversations. Roughly one in three finalists was ghosting between verbal offer and signed contract. Not rejecting. Not negotiating. Going quiet. By the time the hiring manager noticed, the runner-up had been declined and the funnel restarted. Two weeks lost, every time.

Reading the threads top to bottom, the pattern was obvious to a human: enthusiasm decays between reply two and reply five. Email two — "excited to meet the team, when could I start, what stack are you on?" Email five — "Thanks, will review, get back to you soon." No question. No warmth. No forward motion. The drift is invisible in real time because nobody rereads the thread. The hiring manager reads the newest reply, answers it, closes the tab.

That's the gap. Structured stage data says everything is fine. Unstructured tone data says the offer is already dead.

The three-part pipeline: Gmail, gpt-4o-mini, Telegram

The agent does what a careful human would do if they had time to reread every thread every morning. Three parts, ~200 lines of Python total, one cron job at 8am.

  • Collector: Gmail API pulls threads with label hiring-active, splits by sender.
  • Scorer: one prompt to gpt-4o-mini returns a per-reply enthusiasm score 0–10.
  • Alert: Python checks the delta, fires a Telegram message if drift exceeds threshold.

No dashboard. No new tab. The hiring manager already stares at Telegram 40 times a day.

Part 1: Gmail thread parser (the collector)

Standard OAuth, filter by a label the manager applies to any candidate thread. Critical detail: pull the full thread, not the newest message. Split by From: so you have a clean ordered list of just the candidate's replies.

from googleapiclient.discovery import build
import base64, re

def get_candidate_replies(service, thread_id, candidate_email):
    thread = service.users().threads().get(
        userId='me', id=thread_id, format='full'
    ).execute()

    replies = []
    for msg in thread['messages']:
        headers = {h['name']: h['value'] for h in msg['payload']['headers']}
        sender = headers.get('From', '')
        if candidate_email.lower() not in sender.lower():
            continue

        body = extract_plain_text(msg['payload'])
        body = strip_quoted_reply(body)  # drop the >>> quoted history
        if body.strip():
            replies.append({
                'date': headers.get('Date'),
                'text': body.strip()[:2000]  # cap to control tokens
            })
    return replies

def list_hiring_threads(service):
    resp = service.users().threads().list(
        userId='me', labelIds=['Label_hiring-active'], maxResults=100
    ).execute()
    return [t['id'] for t in resp.get('threads', [])]

Two gotchas cost me an afternoon:

  • Gmail returns MIME parts recursively; walk payload.parts until you find text/plain.
  • Strip quoted reply chains before scoring. Otherwise the model scores the manager's enthusiasm, not the candidate's. A simple regex on On <date>, <name> wrote: catches ~95% of clients.

Part 2: Sentiment scorer on gpt-4o-mini

One prompt, one call per thread, JSON out. This is a scoring task, not a reasoning task, so gpt-4o-mini is the right tool. Cost lands at roughly 2 cents per thread. Across the 43-thread backfill: 87 cents total.

The prompt is explicit about what enthusiasm means in a hiring context. Politeness stays constant across a thread — it's noise. Enthusiasm is signal: forward-looking questions, use of the team's or interviewer's name, proposing next steps, specific interest in scope.

from openai import OpenAI
import json

client = OpenAI()

SCORER_PROMPT = """You score candidate enthusiasm in a hiring email thread.

Enthusiasm signals (score up):
- Forward-looking questions about role, team, tools, timeline
- Uses interviewer or company name specifically
- Proposes next steps or availability
- References specific details from the JD or prior call

Neutral signals (do not score up):
- Politeness ("thanks", "appreciate it")
- Acknowledgments ("received", "noted")
- Scheduling confirmations without warmth

Decay signals (score down):
- One-line replies with no question
- Vague timing ("get back to you soon")
- Dropped specifics that were present earlier

Return JSON: [{"idx": 1, "score": 0-10, "reason": "one sentence"}, ...]
"""

def score_thread(replies):
    numbered = "\n\n".join(f"[{i+1}] {r['text']}" for i, r in enumerate(replies))
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SCORER_PROMPT},
            {"role": "user", "content": f"Replies:\n{numbered}\n\nReturn JSON with key 'scores'."}
        ],
        temperature=0.1,
    )
    return json.loads(resp.choices[0].message.content)['scores']

Two design choices that matter:

  • Temperature 0.1. Higher temperatures give inconsistent scoring on the same thread across runs. You want boring, repeatable numbers.
  • Score the whole thread in one call, not per-reply. The model needs to see reply 5 relative to reply 2. Per-reply scoring loses the drift signal entirely.

Part 3: Delta check and Telegram alert

This is where most tutorials would bolt on a dashboard. Don't. The hiring manager already ignores three dashboards. Push the alert where they actually look.

import requests, os

DRIFT_THRESHOLD = 3.0  # points below peak
ACTIVE_STAGES = {'offer_sent', 'post_interview', 'verbal_yes'}

def check_and_alert(candidate, scores, stage):
    if stage not in ACTIVE_STAGES or len(scores) < 3:
        return

    peak = max(s['score'] for s in scores)
    latest = scores[-1]['score']
    drop = peak - latest

    if drop >= DRIFT_THRESHOLD:
        msg = (
            f"⚠️ Drift alert: {candidate['name']}\n"
            f"Stage: {stage}\n"
            f"Peak: {peak}/10 → Latest: {latest}/10 (Δ -{drop:.1f})\n"
            f"Reason: {scores[-1]['reason']}\n"
            f"Thread: {candidate['thread_url']}"
        )
        requests.post(
            f"https://api.telegram.org/bot{os.environ['TG_TOKEN']}/sendMessage",
            json={'chat_id': os.environ['TG_HIRING_CHAT'], 'text': msg}
        )

The >= 3.0 threshold came from grid-searching against the 43-thread backfill. At 2.0 the false positive rate spiked (candidates dip when discussing logistics). At 4.0 I missed two of the nine ghosts. Three was the sweet spot for this agency's tone.

The alert deliberately includes the scorer's one-sentence reason. Without it the manager sees "score dropped" and has to reread the thread — you've just added work. With it, they read one sentence and decide in 10 seconds whether to send a check-in.

Results, false positives, and what the manager actually does

Across the 43-thread backfill:

Metric Value
Threads scored 43
Total token cost $0.87
Threads flagged as drift 11
Flagged candidates who ghosted or declined within 10 days 9
Precision 82%
False positives 2 (both came back; one accepted)
Ongoing daily cost ~$0.04/day (10–20 active threads)

The operational change is small but real. When an alert fires, the hiring manager sends one short human check-in the same day. Not a follow-up. A check-in: "Sensed some hesitation on the last note — anything on your mind about the scope or offer terms? Happy to jump on a quick call."

About half the time the candidate opens up. Compensation gap. Competing offer from a bigger shop. Concern about hours. Now the agency can actually respond instead of finding out through silence ten days later. Even converting one saved finalist per quarter pays for the entire build several times over — a restarted contractor search costs this agency roughly two weeks of recruiter time plus delayed project revenue.

Honest limitations before you copy this

  • Email-only. If your hiring conversations happen in LinkedIn DMs, WhatsApp, or SMS, you need a different collector. The scorer logic transfers; the pipe doesn't.
  • Professional English only. I haven't calibrated the prompt for multilingual threads. Non-native English writers score lower on "warmth" signals unfairly — worth adjusting the prompt or excluding.
  • Signal, not verdict. The agent flags. A human sends the check-in. The moment you let the agent auto-send, the check-in stops working — the whole reason it works is that it reads like a person noticed. Keep the human in the loop on outbound.
  • Small sample. 43 threads is enough to prove the pattern, not enough to publish a paper on. Rerun the threshold search after every 50 new threads.
  • Stage data has to be accurate. The alert only fires on offer_sent / post_interview. If the ATS stage is stale, you'll get noise. A weekly stage sync from the ATS to a local JSON is enough for a team this size.

Where bizflowai.io fits in

This is the shape of work bizflowai.io does weekly for small teams: taking a leaky operational seam — offer-stage ghosting, invoice follow-up, lead qualification — and stitching a narrow agent across the tools the team already uses (Gmail, Telegram, whatever CRM is in play) instead of forcing them into a new platform. The pipeline above runs on a $5/month VPS, costs pennies a day in tokens, and lives entirely inside the client's existing accounts. That's the pattern: small agents that see what platform agents structurally can't.


Want more like this?

I publish practical AI automation, GenAI engineering, and faceless content workflows on YouTube every week.

Subscribe to bizflowai.io on YouTube — never miss a new tutorial.

Planning an AI automation project or need a second opinion on your architecture?

Connect with me on LinkedIn — Lazar Milicevic, GenAI Engineer & bizflowai.io Founder.

Visit bizflowai.io for our services, case studies, and AI consulting.

Frequently asked questions

Why do candidates ghost between verbal offer and signed contract?

Candidates rarely ghost without warning. Analysis of 43 finalist email threads showed a consistent pattern: enthusiasm decays between the second and fifth reply. Early messages ask forward-looking questions about the team, tools, and start dates. Later replies shrink to one-line acknowledgments like 'thanks, will review.' Politeness stays constant, but specific interest fades. Hiring managers miss it because they read only the newest reply, never the full thread top to bottom.

How do I detect candidate drift in email threads automatically?

Build a three-part agent. First, use the Gmail API with OAuth to pull full threads filtered by a label like 'hiring-active,' splitting messages by sender. Second, send the candidate's replies as a numbered list to gpt-4o-mini and request a JSON array with enthusiasm scores (0-10) plus a one-sentence reason per message. Third, in Python, compare the latest score against the thread's peak and alert if it drops more than three points.

Why can't ATS platforms like monday detect candidate ghosting?

ATS and recruiting platforms automate sourcing, resume screening, interview booking, and status updates well, but their agents live inside the platform's data model. They only see structured fields like 'stage moved to offer' or 'candidate marked hot.' They never read the actual Gmail thread between hiring manager and candidate, which is where tone shifts and enthusiasm decay actually appear. The signal exists outside the platform's visibility.

When should I use gpt-4o-mini instead of a larger model for scoring tasks?

Use gpt-4o-mini for scoring and classification tasks rather than complex reasoning. Rating candidate enthusiasm on a 0-10 scale from a numbered list of replies is pattern recognition, not multi-step logic. Cost lands around two cents per thread, totaling 87 cents across 43 threads. Reserve larger, pricier models for tasks requiring genuine reasoning, planning, or nuanced judgment where the accuracy gain justifies the cost.

How accurate is email sentiment scoring for predicting candidate ghosting?

In one real test across 43 finalist threads, the drift-detection agent flagged 11 candidates as risks. Nine of those either declined the offer or went silent within ten days, giving 82% precision. The two false positives were candidates who returned after a delay, and one accepted. This performance targets a signal that platform-native recruiting agents cannot see because they don't read the raw email thread contents.