Skip to content

How to Build an AI Resume Screener in Python: Parse, Score and Explain

How AI resume screening works, and a Python program that screens a folder of CVs against a job: criteria, per-requirement evidence, scores and a ranked shortlist.

Published · 10 minutes read

Cover reading “Build an AI resume screener” with a candidate icon and a chart icon

AI resume screening reads every application for a job and tells a recruiter which ones meet the requirements, and why. Done well, it gives each CV the same careful read as the first ten in the inbox. Done badly, it is a keyword filter with a confident score attached. This guide explains how AI resume screening works, then walks through a Python program that screens a folder of resumes against one job description with the HireLayer APIs, from the parsed CV to an explained shortlist.

How AI resume screening works

Most screening tools, whatever they call themselves, chain the same four steps. Knowing them helps you judge a vendor or build your own feature.

StepWhat it producesHireLayer endpoint
1. Parse each resumeA structured record plus the full text of the CV, including scansPOST /api/v3/parser
2. Turn the job into criteriaWeighted requirements, each flagged mandatory or notPOST /api/v1/jobs/extract-criteria
3. Evaluate each candidateA status and the evidence for every criterion, and a 0–1 scorePOST /api/v1/matching/job-candidate
4. Order the shortlistUp to 10 candidates ranked side by side, each with a rationalePOST /api/v1/matching/job-candidates/rank

The order matters. Criteria are written once per job and reviewed by a person before any candidate is scored, so every application is judged against the same list. The decision to advance or reject someone stays outside the API, in your product, with a name next to it.

Keyword filters versus criterion-based screening

A keyword filter asks whether a word appears in the CV. “Led a support team of six” doesn't contain “people management”, so the filter drops a candidate a recruiter would have kept. It also rewards CVs stuffed with the job ad’s vocabulary, wich is exactly what job seekers are told to do.

Criterion-based screening asks a different question for each requirement: does the resume show it, partly show it, say nothing, or contradict it? HireLayer Match returns one of four statuses per criterion, with a sentence of evidence from the CV:

  • ideal: clearly met.
  • potential: partly or indirectly met.
  • not_mentioned: the resume says nothing about it.
  • not_valid: the resume contradicts it.

The difference between not_mentioned and not_valid is the one that matters most in practice. A CV that never mentions a driving licence is not the same as a CV that says the candidate has none, and your screen shouldn't treat them the same way.

Build the screener in Python

The program below reads a job description from job.txt, screens every resume in an applications folder and writes the results to screening.json for a recruiter to review. You need Python 3.9 or later, pip install requests and an API key in the HIRELAYER_API_KEY environment variable. Keep the key on the server; it never belongs in browser code.

The client

Save this as hirelayer.py. It sends the key in the X-API-Key header, retries server errors and dropped connections with backoff, and raises an error that carries the HTTP status and the request ID for support. It's the same client as in the screening pipeline guide.

# hirelayer.py — minimal client with retries. Requires: pip install requests
import mimetypes
import os
import random
import time

import requests

API_BASE = "https://hirelayer.co/api"


class HireLayerError(Exception):
    def __init__(self, status, message, code=None, request_id=None):
        super().__init__(f"HireLayer {status}: {message}")
        self.status = status
        self.code = code
        self.request_id = request_id


def _delay(attempt, retry_after):
    if retry_after and retry_after.isdigit():
        return int(retry_after)
    return min(30, 2**attempt) + random.random()


def call(method, path, *, json=None, files=None, data=None, timeout=65, max_retries=3):
    """Call HireLayer. Retries 5xx and connection failures, raises HireLayerError otherwise."""
    headers = {"X-API-Key": os.environ["HIRELAYER_API_KEY"]}
    for attempt in range(max_retries + 1):
        try:
            response = requests.request(
                method,
                API_BASE + path,
                headers=headers,
                json=json,
                files=files,
                data=data,
                timeout=timeout,
            )
        except requests.ConnectionError:
            if attempt == max_retries:
                raise
            time.sleep(_delay(attempt, None))
            continue

        if response.ok:
            return response.json()
        if response.status_code >= 500 and attempt < max_retries:
            time.sleep(_delay(attempt, response.headers.get("Retry-After")))
            continue

        try:
            body = response.json()
        except ValueError:
            body = {}
        raise HireLayerError(
            response.status_code,
            body.get("error", response.reason),
            body.get("code"),
            response.headers.get("x-parser-request-id"),
        )


def parse_resume(path, application_id=None):
    content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
    with open(path, "rb") as file:
        content = file.read()  # bytes can be re-sent on retry
    return call(
        "POST",
        "/v3/parser",
        files={"file": (os.path.basename(path), content, content_type)},
        data={"application_id": application_id} if application_id else None,
        timeout=150,
    )

The screening program

Save this as screen.py and run python screen.py. The first run extracts the criteria and stops, so a recruiter can edit criteria.json before anything is scored: drop a requirement the hiring manager doesn't really care about, lower a weight, or clear a mandatory flag. The second run does the screening.

# screen.py — screen every resume in ./applications against one job.
import json
from pathlib import Path

from hirelayer import HireLayerError, call, parse_resume

MAX_TEXT = 50_000  # Match and Rank limit per resume
RESUME_EXTENSIONS = {".pdf", ".doc", ".docx", ".odt", ".ppt", ".pptx", ".odp", ".xls", ".rtf", ".txt", ".jpg", ".jpeg", ".png", ".bmp"}

job_text = Path("job.txt").read_text(encoding="utf-8")

# 1. Turn the job into criteria once, then let a recruiter review them.
criteria_file = Path("criteria.json")
if not criteria_file.exists():
    criteria = call("POST", "/v1/jobs/extract-criteria", json={"job_text": job_text})
    criteria_file.write_text(
        json.dumps(criteria["matching_criteria"], ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    raise SystemExit("Review criteria.json (labels, weights, is_mandatory), then run again.")
criteria = json.loads(criteria_file.read_text(encoding="utf-8"))

# 2. Parse each application and evaluate it against the same criteria.
results = []
for path in sorted(Path("applications").iterdir()):
    if path.suffix.lower() not in RESUME_EXTENSIONS:
        continue
    try:
        resume = parse_resume(str(path), application_id=path.stem)
    except HireLayerError as error:
        if error.status == 422:  # not a resume, unreadable or empty: a person checks it
            results.append({"id": path.stem, "status": "manual_review"})
            continue
        raise

    text = resume["info_resume"]["text"][:MAX_TEXT]
    match = call(
        "POST",
        "/v1/matching/job-candidate",
        json={"job_text": job_text, "candidate_text": text, "matching_criteria": criteria},
    )
    missing = [
        criterion["label"]
        for criterion in match["evaluated_criteria"]
        if criterion["is_mandatory"] and criterion["match_status"] == "not_valid"
    ]
    results.append({
        "id": path.stem,
        "status": "flagged" if missing else "scored",
        "score": match["score"],
        "summary": match["summary"],
        "missing_mandatory": missing,
        "criteria": match["evaluated_criteria"],
        "text": text,
    })

# 3. Order the scored pool. Match scores share the same criteria, so they compare.
scored = sorted(
    (r for r in results if r["status"] == "scored"), key=lambda r: r["score"], reverse=True
)

# 4. Optional: rank the top 10 side by side for a relative order with rationales.
if len(scored) > 1:
    rankings = call(
        "POST",
        "/v1/matching/job-candidates/rank",
        json={
            "job_text": job_text,
            "candidates": [{"id": r["id"], "candidate_text": r["text"]} for r in scored[:10]],
        },
    )["rankings"]
    for item in rankings:
        print(item["rank"], item["candidate_id"], round(item["score"], 2), item["rationale"])

# 5. Save everything for the recruiter's review screen. Nobody is rejected here.
for result in results:
    result.pop("text", None)
Path("screening.json").write_text(
    json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
)

Each application costs two credits, one to parse it and one to evaluate it, plus one for the criteria and one for the optional ranking. Parsing takes about 35 seconds per file, so for more than a handful of CVs run the loop in a background worker and process a few files in parallel. The bulk parsing guide covers queues, retries and duplicates.

What one evaluated criterion looks like

Every criterion you send comes back in the same order with its status and explanation. Explanations and summaries are written in French in the current version, so translate them in your interface if your recruiters work in another language.

{
  "id": "crit_4",
  "label": "Anglais courant",
  "weight": 2,
  "is_mandatory": true,
  "rationale": "Un anglais courant est demandé.",
  "match_status": "potential",
  "match_explanation": "Le CV mentionne un anglais professionnel, sans préciser un niveau courant."
}

Run the screening flow on your own CVs

Create a free account, put a real job description in job.txt and a few anonymized resumes in the folder, then compare the shortlist with your recruiters’ own picks.

Scores, weights and mandatory requirements

The Match score is a weighted average you can recompute yourself: ideal counts 1, potential 0.6, not_mentioned 0.5 and not_valid 0, each multiplied by the criterion’s weight (3 essential, 2 important, 1 nice to have) and divided by the sum of the weights. Because the formula is fixed and the criteria are the same for everyone, scores from seperate calls for the same job can be compared.

The is_mandatory flag does not change the score. That's on purpose: a hard requirement is a business rule, and business rules belong in your code where you can see and change them. The program above flags any candidate with a mandatory criterion marked not_valid instead of dropping them, so a recruiter confirms the reading before anyone is rejected. A not_mentioned mandatory criterion is a question for the candidate, not a reason to reject.

Screening more than 10 applications

Rank orders up to 10 candidates in one request, and its scores only compare candidates inside that request. For a pool of 200 applications, the program uses Match for everyone, sorts by the Match score, then asks Rank to order the top 10 side by side. Honestly, for many teams the Match order is enough and the Rank step is a nice extra: it adds a short rationale comparing each finalist with the others, which helps when two profiles score close together.

If your product already has a search index, you can also pre-filter there (location, work authorization, availability) and only send the remaining applications to Match. Those filters are facts the candidate gave you, so they are easier to explain than a model’s judgement.

Keep a person in charge of the decision

Screening affects who gets a job interview, so build the review step before you build the automation. A few rules hold up well:

  • Show the evidence, not just the number. A recruiter should see each criterion’s status and the sentence behind it, and be able to disagree.
  • Never auto-reject. Flag, sort and summarize; a person clicks the button that ends an application, and your product logs who did.
  • Review the criteria, not only the results. Most bad shortlists start with a requirement nobody needed, like a degree for a role that never used one.
  • Check the outcomes. Compare who gets advanced with who applied, by stage, and look into any gap you can’t explain.

In the EU, AI systems that filter applications or evaluate candidates are listed as high-risk under the AI Act, with obligations for providers and for the employers who deploy them. Our EU AI Act checklist for recruiting software goes through what that means for a screening feature. In the US, check the local rules that apply to automated employment decision tools where you hire, such as New York City’s bias audit requirement.

Production checklist

  • Call the APIs from a background job. The recruiter's screen reads stored results; it doesn't wait for a parse.
  • Store the criteria with the job and the parsed JSON with the candidate. Re-running a call costs a credit and the explanation text can vary slightly between runs.
  • Use your own application IDs as application_id and as Rank ids, so every result maps back to a record.
  • Treat a 422 from the parser as a final outcome for that file (not a resume, unreadable or empty) and route it to a person.
  • Send do_not_store_data=true if you keep your own copy of the files, so HireLayer does not store the resume file.
  • Re-screen when the job changes. New criteria mean new scores; old ones should be marked out of date in your interface.

Finaly, measure before you roll out. Take a closed job with a known shortlist, run the program on its applications and compare the ouput with what your recruiters chose. Where they disagree, read the criterion explanations: they usually point to a requirement that needs rewording.

Frequently asked questions

What is AI resume screening?

Software that reads job applications and assesses them against the requirements of a role, usually by parsing the CV, comparing it with criteria taken from the job description and returning a score or an order. A recruiter then reviews the result and makes the decision.

Is AI resume screening better than keyword filtering?

It catches equivalent experience that a keyword filter misses, and it can say why a requirement is or isn’t met. It still needs a person to review the criteria and the shortlist, because it can misread a CV like any reader.

How many resumes can I screen in one API call?

Parsing and Match handle one resume per call. Rank orders up to 10 candidates for one job in a single call. For larger pools, evaluate every candidate with Match against the same criteria and sort by score.

Can the screener reject candidates automatically?

The API returns statuses, scores and reasons; it never rejects anyone. We recommend flagging candidates who miss a mandatory requirement and letting a recruiter confirm, rather than rejecting them in code.

Which languages and file formats are supported?

The parser reads resumes in 70 languages and accepts PDF, Word, OpenDocument, PowerPoint, text and image files, including scans. Job descriptions can be in any language.

Sources and further reading

  1. HireLayer API documentation: screening pipeline guide
  2. HireLayer API documentation: Match
  3. HireLayer API documentation: Rank
  4. EU AI Act, Annex III: high-risk AI systems
  5. NYC Department of Consumer and Worker Protection: Automated Employment Decision Tools

Louis Desclous

Published on · Reading time: 10 minutes