Home

/

Build It in a Weekend. Run It for Years.

/

Resume Shortlisting

Resume Shortlisting

Chapter 7
Part II
6
min read

From one CV to the whole stack

Screening and CV formatting each worked on one candidate at a time. Useful, but not the job a recruiter actually dreads. The dread is the stack. A role goes live, the applications land (257 of them, on average, for one corporate role in 2025), and somewhere in there is the person who gets placed. Reading them in order, by stopwatch, you give each one about 7.4 seconds and hope you didn't bin the good one with the badly formatted CV.

Shortlisting is the cog that takes the stack as a unit. One job, every CV attached to it, one ranked list out the other end. In the published demo, a screening agent cleared 45 CVs in about 52 seconds: twenty shortlisted, fifteen rejected, ten flagged for a second look. That's the shape of what we're building here. Not a faster reader, but a tireless one that does the whole pile before the kettle boils and shows its working on every line.

You're not ranking 257 CVs by hand. You never were. You were ranking the first forty and calling it a day.

The shape of the job

Three moving parts, in order:

  1. Pull the batch. Get the job's real requirements, then every CV currently attached to that req.
  2. Score each one, then order them. Reuse the screening reasoning per CV, collect the results, and sort into a ranked shortlist.
  3. Write it back. Put the ranked list, and the reasoning, where the recruiter already works: the ATS.

Notice what shortlisting isn't. It isn't a new way to judge a CV. That's the screening cog, called once per candidate. Shortlisting is the batching, the ordering, and the writing-back around it. The intelligence is borrowed; the value here is throughput and a clean handover.

Pulling the batch

Bullhorn first, as always. A req is a JobOrder; the candidates in play hang off it through associations, and each candidate's CV lives in fileAttachments. You fetch the job, fetch its candidates, and for each one pull the resume text via the parser's populateDescription. There's no separate convert-to-text call.

// Illustrative excerpt — not a copy-paste product.
// 1. The req's real requirements.
var job = await _bullhorn.GetAsync<JobOrder>(
    $"entity/JobOrder/{jobId}?fields=id,title,clientCorporation,employmentType");

// 2. Candidates attached to this req, paged (start += count).
var candidates = await _bullhorn.ListAssociatedCandidatesAsync(jobId);

// 3. For each, the CV as plain text — via the resume parser, not a convert endpoint.
foreach (var c in candidates)
{
    var attachments = await _bullhorn.GetAsync<FileAttachmentList>(
        $"entity/Candidate/{c.Id}/fileAttachments?fields=id,name,contentType,isResume");
    var resumeFile = attachments.Data.FirstOrDefault(a => a.IsResume);
    c.ResumeText = await _bullhorn.ParseResumeTextAsync(c.Id, resumeFile?.Id);
}

JobAdder is the same job with different nouns. A req is a Job, its requirements live in skillTags.tags, and there's no parsed-resume-text endpoint: you list the candidate's résumé attachment and pull the raw file. Every call carries an OAuth2 bearer token and is sent to the per-account api base URL returned with the token, not a hard-coded host.

// Illustrative excerpt. JobAdder: OAuth2 bearer on every call, sent to the
// per-account "api" base URL stored alongside the token.
var job = await _jobAdder.GetAsync<Job>($"jobs/{jobId}");   // skills in job.SkillTags.Tags

// Candidates are listed/paged; here, those linked to this req.
var matches = await _jobAdder.ListCandidatesForJobAsync(jobId);  // offset/limit, follow links.next

foreach (var c in matches)
{
    // No parsed-text endpoint — list the latest résumé, fetch the raw file, extract text.
    var atts = await _jobAdder.GetAsync<AttachmentList>(
        $"candidates/{c.Id}/attachments?type=Resume&latest=true");
    var resume = atts.Items.FirstOrDefault();
    var bytes = await _jobAdder.GetBytesAsync(
        $"candidates/{c.Id}/attachments/{resume?.AttachmentId}");
    c.ResumeText = _textExtractor.Extract(bytes, resume?.FileType);
}
// Bearer set centrally: access_token (~60 min) refreshed via rotating refresh_token
// (needs offline_access) — persist the new refresh token on every refresh.

Two things to flag honestly. Bullhorn's /search index is eventually consistent: a CV uploaded a minute ago may not show up yet. So for "every candidate on this req, right now" we use /query against the database, not /search. And paging is mandatory: you loop start += count until you've drained the list, because a popular req will blow past a single page.

Scoring, then ordering

Each CV goes through the same scoring you already trust from the screening cog, and through the same guarded gateway. Every model call routes through ILlmGateway: allowlisted structured fields in, DLP inspection, fail closed, then the call. The agent never touches the model SDK raw. That's not a shortlisting detail; it's the rule everywhere. Shortlisting is just where the rule starts to matter, because you're no longer feeding the model one CV. You're feeding it the whole stack of real, named, human CVs at once.

// Illustrative excerpt. One scored result per CV, gathered into a list.
var scored = new List<ScoredCandidate>();
foreach (var c in candidates)
{
    // Same screening function as Use Case 1 — reused, not reinvented.
    var result = await _kernel.InvokeAsync<CandidateScore>(
        _screen["ScoreAgainstJob"],
        new() { ["jobRequirements"] = job.Requirements, ["cv"] = c.ResumeText });

    scored.Add(new ScoredCandidate(c.Id, c.Name, result.Score,
        result.Reasoning, result.Flags));   // flags = "verify", "missing", etc.
}

// The ordering. Score descending; flagged-but-strong candidates kept visible.
var shortlist = scored
    .OrderByDescending(s => s.Score)
    .ThenBy(s => s.Flags.Count)   // a clean strong CV outranks a flagged one of equal score
    .ToList();

A word on ordering, because it's where shortlisting quietly goes wrong. Ranking by a single number feels clean. It isn't. A keyword-stuffed CV scores high; a brilliant candidate with a one-line CV scores low. So the rank is a starting order, not a verdict, and the flags travel with it. A candidate the model wasn't sure about doesn't get silently buried at position 38; they're ranked and flagged, so a human sees both. The list orders the work. It doesn't make the decision.

Rank is an opinion with a number on it. Keep the opinion visible and the human in charge of acting on it.

A ranked shortlist: candidate, score and one-line reasoning per row, three rows flagged amber for verify, with a human approve control beside each.

Writing the shortlist back

The shortlist is worthless as a console printout. It has to land where the recruiter already lives, the ATS, and it has to carry its reasoning, so that six weeks later, when a client asks why candidate X made the cut and candidate Y didn't, the answer is on the record. This is also the compliance line: a ranked employment shortlist with no recorded reasoning is exactly the kind of opaque automated decision the EU AI Act treats as high-risk. Visible reasoning isn't a nicety. It's the audit trail.

In Bullhorn, you write a Note per candidate, linked to both the candidate and the req, with the score and reasoning in the comments.

// Illustrative excerpt. One Note per shortlisted candidate, linked to the req.
foreach (var (s, rank) in shortlist.Select((s, i) => (s, i + 1)))
{
    await _bullhorn.PutAsync("entity/Note", new
    {
        action = "AI Shortlist",
        comments = $"Rank {rank}/{shortlist.Count} · score {s.Score}/100\n"
                 + $"{s.Reasoning}"
                 + (s.Flags.Any() ? $"\nFlags: {string.Join(", ", s.Flags)}" : ""),
        personReference = new { id = s.CandidateId },
        jobOrder = new { id = jobId }
    });
}
// Returns { changedEntityId, changeType:"INSERT" }; the Note links via NoteEntity.

JobAdder takes the equivalent as a Note on the candidate. It has no separate "activity" resource, so activities are modelled as notes. The text field is required; the call returns 201 with a NoteModel.

// Illustrative excerpt. JobAdder: POST a candidate note (activities = notes).
await _jobAdder.PostAsync($"candidates/{s.CandidateId}/notes", new
{
    text = $"AI shortlist · rank {rank} · score {s.Score}/100 · {s.Reasoning}"
});

What we deliberately don't do here is change anyone's candidate status, advance them through the pipeline, or notify a client. Those are consequential actions, and consequential actions stay on the leash. The agent ranks the stack and writes its reasoning; a human reads the shortlist and decides who actually moves forward. Automate the work. Don't automate the accountability.

The agent does the tireless 90%: reading 257 CVs and ordering them. You own the moment of consequence: clicking "advance."

What you've actually built

Three cogs, now. Screen one CV, format and redact one CV, and rank a whole stack of them. Each one a small, specific tool that does a job your team currently does by stopwatch. Built in C#, wired to Bullhorn and JobAdder, every model call on a guarded gateway, every consequential action behind a human. None of it took a transformation programme. Most of it took a weekend.

Which is exactly the problem the rest of this book is about. Because the demo works, and the demo is the easy part.

Next: That Was Easy, a victory lap, and the trap hiding inside it.

the-math-no-recruiter-can-win-by-hand
what-an-ai-agent-actually-is
the-leash
the-toolkit
the-model-small-capable-swappable
talking-to-your-ats
use-case-1-resume-screening-against-a-job
the-shape-of-the-loop
running-it-thought-action-observation
use-case-2-cv-formatting-redacting-for-clients
reformatting-into-your-branded-template
resume-shortlisting
that-was-easy
security-compliance
keeping-pii-out-of-the-llm
exceptions-reliability
silent-api-drift-the-ats-changes-under-you
when-it-fails-anyway-dead-letter-and-the-leash
monitoring-observability
maintenance-the-lifecycle
the-scorecard-success-metrics-kpis
build-vs-buy-vs-managed
what-an-engineer-actually-costs
what-the-wider-data-says-happens-next
conclusion-how-this-gets-run-for-you
the-promises-behind-the-service
fuller-code-listings
one-full-screening-react-loop-semantic-kernel
env-deployment-reference
secrets-in-dev-vs-production
bullhorn-jobadder-endpoint-cheat-sheets
sources-further-reading
compliance-primary-law-sources

Download the full PDF for free?

Download full PDF
build-it-in-a-weekend.pdf
Oops! Something went wrong while submitting the form.
Related Chapters