Build an Overnight Recruiting Assistant

Screen PDF resumes overnight with Claude and wake up to a ranked report.

Introduction

30 Second Summary

Hiring managers spend hours reading resumes, only to realize they forgot which candidate had the right Python experience and which one was the Docker expert. It is repetitive, error-prone work that nobody enjoys doing at 7am.

In this project, you will build a Python script that uses Claude to read PDF resumes overnight and produce a ranked shortlist with scores, strengths, and red flags ready for you in the morning.

What You'll Build

You drop a stack of resumes into a folder before bed, and by morning your browser opens a color-coded report showing exactly who to interview first.

By the end of this project, you'll have:

  • A resume screening pipeline where you drop PDFs into a folder and get a structured AI evaluation for each candidate, including a 0-100 score across multiple dimensions.
  • A polished HTML report that ranks candidates with color-coded badges, written strengths, red flags, and expandable detail sections you can scan over coffee.
  • An automated overnight schedule using macOS launchd that runs the screening at 2am and catches up automatically if your laptop was asleep.
  • Secret Mission: A bias-check system that screens each resume twice with different prompt framings and flags candidates whose scores swing wildly between passes.

Are there any prerequisites?

None. This project walks you through installing Python, setting up your API key, and configuring the scheduler from scratch. All you need is a Mac and an internet connection.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Python Environment and API Key

Your Python environment is ready with the anthropic SDK installed. The missing piece is access to Claude itself.

In this step, you'll create an Anthropic API key, add credit to your account, and store the key safely so your script can authenticate with Claude when it screens resumes.

In this step, get ready to:
  • Activate your virtual environment.
  • Create an Anthropic API key and add API credit.
  • Store your key in a .env file and verify the setup.
Activate your virtual environment

A virtual environment keeps your project's packages separate from the rest of your system. You need to activate it each time you open a new terminal session.

  • Switch to Cursor's built-in terminal.
  • Activate the virtual environment by running this command:
source .venv/bin/activate

You should see (.venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Don't see (.venv) in your prompt?

Make sure your terminal is inside the recruiting-assistant folder. Run pwd to check your current directory.

If you see a 'No such file or directory' error, the .venv folder may not exist. Recreate it by running python3 -m venv .venv and then try the activate command again.

Help me activate my virtual environment.

Get your Anthropic API key

Your script needs an API key to authenticate with Claude. You'll create one on Anthropic's developer console and add a small amount of credit so the API calls work.

  • Open your browser and go to console.anthropic.com.
  • Sign up for a new account (or sign in if you already have one). You can use Google or email.
  • Once signed in, navigate to the API Keys section in the console sidebar or settings.
  • Click the button to create a new API key.
  • Give your key a name like recruiting-assistant so you can identify it later.
  • Copy the key immediately. You will only see it once.

Copy your key now

Anthropic only displays your full API key at the moment of creation. If you close the modal without copying it, you will need to create a new key. Paste it somewhere safe (like a text editor) temporarily.

  • Next, navigate to the Billing or Plans section in the console.
  • Add $5 in API credit. This is enough for hundreds of resume screenings (each one costs fractions of a cent).

How much does screening cost?

Claude Haiku 4.5 charges $1.00 per million input tokens and $5.00 per million output tokens. A typical resume is 1-3 pages, which translates to roughly 2,000-5,000 tokens. That means each resume costs about $0.001-$0.003 to screen.

Your $5 credit will comfortably cover this entire project and leave plenty of room for experimenting.

Store your key in a .env file

Hardcoding an API key directly in your Python script is a security risk. Instead, you store it in a .env file that the python-dotenv package loads at runtime.

  • In Cursor's file explorer, create a new file called .env in the root of your recruiting-assistant folder.
  • Add your API key to the file by pasting the following:
ANTHROPIC_API_KEY=your-api-key-here
  • Replace your-api-key-here with the actual API key you copied from the Anthropic Console.
  • Save the file.

What does .env do?

The .env file holds environment variables that your code reads at startup. The python-dotenv library loads these variables so os.environ.get("ANTHROPIC_API_KEY") returns your key without it ever appearing in your source code.

If you ever push this project to GitHub, add .env to your .gitignore so the key stays private.

Before you run the next command, what do you think it will print if everything is set up correctly?

  • Verify your environment works by running this command:
python3 -c "import anthropic; print(anthropic.__version__)"

You should see a version number printed (like 0.120.2). This confirms the SDK is installed and importable from your active virtual environment.

Seeing ModuleNotFoundError?

This means the anthropic package is not visible to the Python you just ran. Make sure your virtual environment is active. You should see (.venv) in your terminal prompt.

If the prompt shows (.venv) but you still get the error, reinstall the package by running pip install anthropic and try again.

Help me fix my anthropic import error.

✔️ Awesome, I've got everything!

Great. Double check that you've saved your .env file.

ⓧ I'd like to double check the full code

Your .env file should contain exactly one line:

ANTHROPIC_API_KEY=your-api-key-here

Replace your-api-key-here with your actual key from the Anthropic Console. No quotes, no spaces around the = sign.

Your environment is locked and loaded. Next up, you'll write the script that sends a real PDF resume to Claude and gets a structured evaluation back.

Screen Your First Resume

Before automating anything, you need to prove the core concept works. Can Claude actually read a PDF resume and return a structured evaluation?

In this step, you will define the job you are hiring for, write a Python script that sends a resume to Claude as a PDF document, and see a scored evaluation come back in your terminal.

In this step, get ready to:
  • Create a job description file that defines the role you are hiring for.
  • Write a screening script that sends a PDF resume to Claude and gets back a structured score.
  • Run the script against a real resume and see scores, strengths, and red flags printed in your terminal.
Create the job description file

Your screening script needs something to evaluate resumes against. A job description file defines the title, required skills, and experience level so Claude knows what "good" looks like for this role.

  • Create a new file called job_description.txt in your recruiting-assistant folder by pasting the following content:
Title: Senior Backend Engineer

Company: Acme Corp

About the role:
We are looking for a Senior Backend Engineer to join our platform team. You will design and build scalable APIs that serve millions of requests per day.

Required skills:
- Python or Go (3+ years production experience)
- REST API design and implementation
- PostgreSQL or similar relational database
- Docker and container orchestration
- CI/CD pipelines

Nice to have:
- Experience with message queues (RabbitMQ, Kafka)
- Kubernetes
- System design for high-traffic applications
- Open source contributions

Experience level: 5+ years in backend engineering

What does this file do?

This is the benchmark your AI recruiter evaluates every resume against. Claude will compare each candidate's experience, skills, and trajectory to these requirements and produce a numerical score.

You can swap this file out for any role. The script reads it fresh on every run.

  • Save job_description.txt.
  • Confirm the file exists by running:
cat job_description.txt

You should see the full job description printed in your terminal.

Don't see the text?

Make sure you saved the file in the recruiting-assistant folder (not inside a subfolder). Run ls to confirm job_description.txt appears alongside your .env file.

help me find where my job_description.txt file was saved

Write the screening script

Now for the main event. You will build a Python script that loads the job description, reads a PDF resume, and sends both to Claude for evaluation.

  • Create a new file called screen.py in your recruiting-assistant folder. Start with the imports, configuration, and the job description loader by pasting this code:
import os
import sys
import json
import base64
import webbrowser
from pathlib import Path
from datetime import datetime

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

RESUMES_DIR = Path("resumes")
OUTPUT_DIR = Path("output")
JOB_DESC_FILE = Path("job_description.txt")

MODEL = "claude-haiku-4-5-20251001"


def load_job_description():
    if not JOB_DESC_FILE.exists():
        print(f"Error: {JOB_DESC_FILE} not found.")
        sys.exit(1)
    return JOB_DESC_FILE.read_text()

What does this code do?

load_dotenv() reads your .env file so the API key is available as an environment variable. The Anthropic client is initialized once and reused for all API calls.

The constants (RESUMES_DIR, OUTPUT_DIR, JOB_DESC_FILE, MODEL) keep paths and the model ID in one place so they are easy to change later.

  • Next, add the screen_resume() function below load_job_description(). This function base64-encodes a PDF and builds the scoring prompt:
def screen_resume(pdf_path, job_description):
    pdf_data = base64.standard_b64encode(pdf_path.read_bytes()).decode("utf-8")

    prompt = f"""You are an expert recruiter screening resumes against a job description.

JOB DESCRIPTION:
{job_description}

Evaluate this resume against the job description. Return ONLY valid JSON with this exact structure:
{{
    "candidate_name": "Full Name from resume",
    "score": 0-100,
    "experience_match": 0-40,
    "skills_match": 0-30,
    "growth_potential": 0-15,
    "communication": 0-15,
    "strengths": ["strength 1", "strength 2", "strength 3"],
    "red_flags": ["flag 1"] or [],
    "recommendation": "interview" or "maybe" or "reject",
    "summary": "One sentence summary of fit"
}}

Score breakdown:
- experience_match (0-40): Years and relevance of experience
- skills_match (0-30): Technical/skill alignment with requirements
- growth_potential (0-15): Career trajectory and learning signals
- communication (0-15): Resume clarity and professionalism

The total score should equal the sum of all sub-scores."""

What does this prompt do?

The prompt gives Claude a structured role (expert recruiter), the job description as context, and an exact JSON schema to fill in. Specifying the schema forces Claude to return machine-parseable output instead of free-form text.

The sub-scores (experience, skills, growth, communication) add up to the total score. This makes the evaluation transparent and auditable.

  • Save screen.py and confirm the module loads correctly by running:
python3 -c "from screen import load_job_description; print('Loaded:', len(load_job_description()), 'chars')"

You should see something like Loaded: 453 chars printed, confirming both the import and the job description file read work.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). Also confirm you are running the command from inside the recruiting-assistant folder where screen.py lives.

help me fix my screen.py import error

  • Now add the rest of screen_resume() directly below the prompt string. This sends the PDF to Claude and parses the JSON response:
    message = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": pdf_data,
                        },
                    },
                    {"type": "text", "text": prompt},
                ],
            }
        ],
    )

    response_text = message.content[0].text
    try:
        result = json.loads(response_text)
    except json.JSONDecodeError:
        start = response_text.find("{")
        end = response_text.rfind("}") + 1
        if start != -1 and end > start:
            result = json.loads(response_text[start:end])
        else:
            raise ValueError(f"Could not parse JSON from response: {response_text[:200]}")

    result["file"] = pdf_path.name
    return result

What does this code do?

The messages list sends two content blocks to Claude: the PDF as a base64-encoded document and the text prompt. Claude reads the PDF natively without any parsing library.

The try/except block handles cases where Claude wraps JSON in extra text. It falls back to extracting the first {...} block from the response.

Why base64 instead of a PDF parsing library?

Claude reads PDFs natively via the document content block. This preserves formatting, tables, and visual layout that text-extraction libraries often mangle. You get better results with fewer dependencies.

  • Finally, add the main block at the bottom of screen.py to tie everything together:
if __name__ == "__main__":
    job_description = load_job_description()
    pdf_files = list(RESUMES_DIR.glob("*.pdf"))
    if pdf_files:
        result = screen_resume(pdf_files[0], job_description)
        print(json.dumps(result, indent=2))
    else:
        print("No PDF files found in resumes/")

What does this code do?

The main block loads the job description, finds the first PDF in the resumes/ folder, screens it, and pretty-prints the JSON result. This is the quick single-resume test before you add batch processing in the next step.

  • Save screen.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved screen.py and move on to running it.

ⓧ I'd like to double check the full code

Your screen.py should contain these sections in order. Scroll up and confirm each chunk is present:

  • Imports (os, sys, json, base64, webbrowser, Path, datetime, Anthropic, load_dotenv).
  • load_dotenv() call and client initialization.
  • Four constants: RESUMES_DIR, OUTPUT_DIR, JOB_DESC_FILE, MODEL.
  • load_job_description() function.
  • screen_resume() function with the prompt, API call, and JSON parsing.
  • if __name__ == "__main__" block that screens the first PDF and prints the result.
Run the script against a sample resume

Your script is ready. Now you need a PDF resume to test it against.

  • Create the resumes/ folder by running:
mkdir resumes
  • Add at least one PDF resume to the resumes/ folder. You can use your own resume, a friend's (with permission), or any sample resume PDF you find online.
  • Confirm the PDF is in place by running:
ls resumes/

You should see at least one .pdf file listed.

Any PDF resume works

Claude reads PDFs natively, so formatting does not matter. A simple one-page resume works just as well as a fancy designed one. If you do not have a resume handy, search "sample resume PDF" online and download one.

Before you run the script, what do you think will come back? A plain text summary, or something more structured?

  • Run the screening script:
python3 screen.py

Your terminal prints a JSON object with a numerical score (0-100), a list of strengths, and a list of red flags. The output looks something like this:

{
  "candidate_name": "Jane Smith",
  "score": 72,
  "experience_match": 30,
  "skills_match": 22,
  "growth_potential": 12,
  "communication": 8,
  "strengths": [
    "Strong Python experience",
    "REST API design background",
    "Clear resume formatting"
  ],
  "red_flags": [
    "No Docker experience listed"
  ],
  "recommendation": "interview",
  "summary": "Solid backend engineer with strong Python skills.",
  "file": "jane_smith_resume.pdf"
}

What should I see?

Your exact scores and text will differ based on the resume you used. The important thing is that you see valid JSON with all the expected fields: candidate_name, score, sub-scores, strengths, red_flags, recommendation, and summary.

Seeing an authentication error?

Double check that your .env file contains a valid API key and that you have added credit to your Anthropic account at console.anthropic.com. The key should start with sk-ant-.

help me fix my Anthropic API authentication error

Seeing 'No PDF files found in resumes/'?

Make sure your file has a .pdf extension and is inside the resumes/ folder (not the project root). Run ls resumes/ to verify.

help me figure out why my script can't find my PDF resume

You just proved that Claude can read a raw PDF and return a structured evaluation in seconds. Next up, you will scale this to process an entire folder of resumes and handle errors gracefully.

Process All Resumes and Handle Errors

Your script screens one resume at a time. A real recruiter receives dozens of resumes in a single day.

In this step, you will upgrade screen.py to loop through every PDF in the folder, handle failures gracefully, and output a ranked summary. One bad file will not crash the entire batch.

In this step, get ready to:
  • Loop through every PDF in the resumes folder and screen each one.
  • Handle errors so a corrupt or oversized file does not crash the batch.
  • Sort all results by score and print a ranked summary.
Write the batch screening function

Right now the __main__ block picks the first PDF and exits. You need a dedicated function that processes the entire folder.

  • In screen.py, scroll to the blank space below the screen_resume() function (after its last line return result).
  • Add the following screen_all_resumes() function by pasting this code:
def screen_all_resumes(job_description):
    # Collect screening results for every PDF
    results = []

    if not RESUMES_DIR.exists():
        print(f"Error: {RESUMES_DIR}/ folder not found. Create it and add PDF resumes.")
        sys.exit(1)

    pdf_files = list(RESUMES_DIR.glob("*.pdf"))
    if not pdf_files:
        print(f"No PDF files found in {RESUMES_DIR}/")
        sys.exit(1)

    print(f"Found {len(pdf_files)} resume(s) to screen...\n")

    for pdf_path in pdf_files:
        try:
            print(f"  Screening: {pdf_path.name}...", end=" ")
            result = screen_resume(pdf_path, job_description)
            results.append(result)
            print(f"Score: {result['score']}/100 ({result['recommendation']})")
        except Exception as e:
            # Skip bad files without crashing the batch
            print(f"WARNING: Skipped - {e}")

    # Rank candidates from highest to lowest score
    results.sort(key=lambda x: x["score"], reverse=True)
    return results

What does this code do?

  • The function first checks that the resumes/ folder exists and contains PDFs. If not, it exits early with a clear message.
  • The for loop screens each PDF one by one. The try/except block catches any error (corrupt PDF, API timeout, oversized file) and prints a WARNING instead of crashing.
  • After all files are processed, results.sort() ranks candidates from highest score to lowest so the best matches appear first.

Now you need the script's entry point to call this new function instead of screening just one file.

  • Scroll to the bottom of screen.py and find the existing if __name__ == "__main__": block:
if __name__ == "__main__":
    job_description = load_job_description()
    pdf_files = list(RESUMES_DIR.glob("*.pdf"))
    if pdf_files:
        result = screen_resume(pdf_files[0], job_description)
        print(json.dumps(result, indent=2))
    else:
        print("No PDF files found in resumes/")
  • Delete that entire block and replace it with this updated entry point:
if __name__ == "__main__":
    job_description = load_job_description()
    results = screen_all_resumes(job_description)
    if results:
        # Print a quick ranked summary
        for r in results:
            print(f"{r.get('candidate_name', 'Unknown')}: {r['score']}/100")
    else:
        print("No resumes were successfully screened.")

What changed in the entry point?

Instead of screening one file and dumping raw JSON, the script now calls screen_all_resumes() to process every PDF. After screening, it prints a clean one-line summary for each candidate showing their name and score.

  • Save screen.py.
  • Run the script to confirm it works with the resume you already have by running:
python3 screen.py

You should see the script find your resume, screen it, and print a score line like Candidate Name: 72/100.

Seeing an error about the resumes folder?

  • Make sure you still have at least one PDF in the resumes/ folder from the previous step.
  • Check that you are running the command from inside the recruiting-assistant folder where screen.py lives.
  • Confirm your virtual environment is still active (you should see (.venv) in your terminal prompt).

Still stuck? Help me debug why screen.py cannot find my resumes folder or PDF files.

Test with multiple resumes and a bad file

The error handling only proves itself when something goes wrong. You need to feed the script a file that will fail so you can confirm the batch keeps running.

  • Add at least 2 more PDF resumes to your resumes/ folder (any real or sample resume PDFs will work).
  • Create a deliberately bad file to test error handling by running:
echo "this is not a real pdf" > resumes/bad_file.pdf

Why a fake PDF?

This file has a .pdf extension but contains plain text. When Claude tries to read it as a PDF document, the API will reject it. Your error handling should catch this and print a WARNING instead of crashing the entire script.

  • Confirm your test files are in place by running:
ls resumes/

You should see 3 or more files listed, including bad_file.pdf.

Before you run the script, think about what you expect to happen. Will every file get a score? What will happen when it hits the fake PDF?

  • Run the batch screening by running:
python3 screen.py

The script processes all valid PDFs and prints their scores. When it hits bad_file.pdf, you see a WARNING line instead of a crash. After the warnings, the ranked summary prints all successfully screened candidates from highest to lowest score.

Script crashes instead of printing a warning?

  • Check that your try/except block wraps the entire screen_resume() call and the results.append() line. If the except only catches part of the logic, errors slip through.
  • Make sure the except Exception as e: line is indented at the same level as try:. Python is sensitive to indentation mismatches.

Need more help? Help me fix my error handling in screen_all_resumes so bad files do not crash the batch.

✔️ Awesome, I've got everything!

Great. Double check you have saved screen.py and confirm the ranked output appears when you run the script.

ⓧ I'd like to double check the full code

Your screen.py file is now too long to show in one block. Scroll through your file and confirm these sections appear in order:

  • Imports (os, sys, json, base64, webbrowser, Path, datetime, Anthropic, load_dotenv) and client setup at the top.
  • Constants: RESUMES_DIR, OUTPUT_DIR, JOB_DESC_FILE, MODEL.
  • The load_job_description() function.
  • The screen_resume() function with the full prompt and API call.
  • The new screen_all_resumes() function with the loop, try/except, and sort.
  • The updated if __name__ == "__main__": block that calls screen_all_resumes() and prints the ranked summary.

Your script now handles an entire folder of resumes in one run. Next up, you will turn that terminal output into a polished HTML report you can read over coffee.

Generate the HTML Report

Right now your script prints JSON to the terminal. That works for debugging, but it is not something you want to scan over coffee with a dozen candidates.

In this step you will add a function that transforms the sorted results into a polished HTML report. Candidates get color-coded score badges, expandable detail sections, and a stats summary at the top. The report auto-opens in your browser so it is ready the moment you sit down.

In this step, get ready to:
  • Add a generate_report() function with color-coded score badges and expandable candidate details.
  • Write the report to an output/ folder with a timestamped filename.
  • Auto-open the finished report in your default browser.
Add the report scaffolding and candidate loop

The report function needs to decide where to write the file, how to name it, and how to color-code scores. You will start with the setup logic, two small helper functions, and the loop that prepares data for each candidate's card.

  • In screen.py, scroll down to the blank line just above the if __name__ == "__main__": block.
  • Add the beginning of the generate_report() function by pasting this code above the if __name__ block:
def generate_report(results):
    # Create output directory and build timestamped filename
    OUTPUT_DIR.mkdir(exist_ok=True)
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
    report_path = OUTPUT_DIR / f"screening_report_{timestamp}.html"

    # Map scores to badge colors (green/yellow/red)
    def score_color(score):
        if score >= 70:
            return "#22c55e"
        elif score >= 50:
            return "#eab308"
        else:
            return "#ef4444"

    # Map scores to human-readable labels
    def score_label(score):
        if score >= 70:
            return "Strong"
        elif score >= 50:
            return "Maybe"
        else:
            return "Weak"

    # Build an HTML card for each candidate
    candidates_html = ""
    for i, r in enumerate(results, 1):
        strengths_list = "".join(f"<li>{s}</li>" for s in r.get("strengths", []))
        flags_list = "".join(f"<li>{f}</li>" for f in r.get("red_flags", []))
        if not flags_list:
            flags_list = "<li>None identified</li>"

        color = score_color(r["score"])
        label = score_label(r["score"])

What does this code do?

  • OUTPUT_DIR.mkdir(exist_ok=True) creates the output/ folder if it does not already exist.
  • The timestamp goes into the filename so each run produces a new report instead of overwriting the previous one.
  • score_color() returns green for 70+, yellow for 50-69, and red below 50.
  • The for loop iterates over every screened candidate, converting their strengths and red flags arrays into HTML list items.
  • Directly below the label = score_label(r["score"]) line you just pasted, add the HTML card template that each candidate gets:
        candidates_html += f"""
        <div class="candidate">
            <div class="candidate-header">
                <div>
                    <span class="rank">#{i}</span>
                    <span class="name">{r.get('candidate_name', 'Unknown')}</span>
                    <span class="file">({r['file']})</span>
                </div>
                <div class="score-badge" style="background-color: {color};">
                    {r['score']}/100 - {label}
                </div>
            </div>
            <p class="summary">{r.get('summary', '')}</p>
            <details>
                <summary>View Details</summary>
                <div class="details-grid">
                    <div>
                        <h4>Sub-scores</h4>
                        <ul>
                            <li>Experience Match: {r.get('experience_match', 'N/A')}/40</li>
                            <li>Skills Match: {r.get('skills_match', 'N/A')}/30</li>
                            <li>Growth Potential: {r.get('growth_potential', 'N/A')}/15</li>
                            <li>Communication: {r.get('communication', 'N/A')}/15</li>
                        </ul>
                    </div>
                    <div>
                        <h4>Strengths</h4>
                        <ul class="strengths">{strengths_list}</ul>
                    </div>
                    <div>
                        <h4>Red Flags</h4>
                        <ul class="flags">{flags_list}</ul>
                    </div>
                </div>
                <p><strong>Recommendation:</strong> {r.get('recommendation', 'N/A').upper()}</p>
            </details>
        </div>
        """

What does this template build?

For each candidate the loop creates a card with three visible layers:

  • A header row showing their rank, name, source file, and a color-coded badge with their overall score.
  • A one-line summary generated by Claude.
  • A collapsible <details> section the reader can expand to see sub-scores, strengths, red flags, and the final recommendation.
  • Save screen.py.
  • Confirm your code has no syntax errors by running:
python3 -c "import ast; ast.parse(open('screen.py').read()); print('No syntax errors')"

You should see No syntax errors printed. If you see a SyntaxError instead, check that the triple-quote """ at the end of the template has the correct indentation (8 spaces).

Seeing a SyntaxError?

  • Make sure the candidates_html += f""" line is indented 8 spaces (inside the for loop). The closing """ must also be at 8 spaces.
  • Check that generate_report is defined at the module level (no leading indentation on the def line).

Still stuck? Help me fix a SyntaxError in my generate_report function.

Write the full-page HTML template

The candidate cards need a full HTML page wrapper with a title, a stats bar, and CSS styling. This section assembles the page, writes it to disk, and opens it in your browser.

  • Directly below the closing """ of the candidates loop (still inside the generate_report function), paste the start of the page template:
    html = f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Screening Report - {timestamp}</title>
    <style>
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 900px; margin: 0 auto; padding: 40px 20px; background: #f8fafc; color: #1e293b; }}
        h1 {{ color: #0f172a; margin-bottom: 4px; }}
        .subtitle {{ color: #64748b; margin-bottom: 32px; }}
        .stats {{ display: flex; gap: 24px; margin-bottom: 32px; }}
        .stat {{ background: white; padding: 16px 24px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
        .stat-value {{ font-size: 24px; font-weight: bold; }}
        .stat-label {{ color: #64748b; font-size: 14px; }}
        .candidate {{ background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
        .candidate-header {{ display: flex; justify-content: space-between; align-items: center; }}
        .rank {{ font-weight: bold; color: #64748b; margin-right: 8px; }}
        .name {{ font-size: 18px; font-weight: 600; }}
        .file {{ color: #94a3b8; font-size: 13px; margin-left: 8px; }}
        .score-badge {{ color: white; padding: 6px 14px; border-radius: 20px; font-weight: 600; font-size: 14px; }}
        .summary {{ color: #475569; margin: 8px 0; }}
        details {{ margin-top: 12px; }}
        summary {{ cursor: pointer; color: #3b82f6; font-weight: 500; }}
        .details-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 12px; }}
        .details-grid h4 {{ margin: 0 0 8px 0; color: #334155; }}
        .details-grid ul {{ margin: 0; padding-left: 20px; }}
        .details-grid li {{ margin-bottom: 4px; font-size: 14px; }}
        .strengths li {{ color: #16a34a; }}
        .flags li {{ color: #dc2626; }}
    </style>
</head>
  • Immediately below the </head> line (continuing the same f-string with no gap), paste the body section and file-writing logic:
<body>
    <h1>Candidate Screening Report</h1>
    <p class="subtitle">Generated {datetime.now().strftime('%B %d, %Y at %I:%M %p')}</p>
    <div class="stats">
        <div class="stat">
            <div class="stat-value">{len(results)}</div>
            <div class="stat-label">Candidates Screened</div>
        </div>
        <div class="stat">
            <div class="stat-value">{sum(1 for r in results if r.get('recommendation') == 'interview')}</div>
            <div class="stat-label">Recommended for Interview</div>
        </div>
        <div class="stat">
            <div class="stat-value">{results[0]['score'] if results else 0}/100</div>
            <div class="stat-label">Top Score</div>
        </div>
    </div>
    {candidates_html}
</body>
</html>"""

    report_path.write_text(html)
    print(f"\nReport saved: {report_path}")
    webbrowser.open(f"file://{report_path.resolve()}")
    return report_path

What does this page template do?

  • The <style> block uses CSS grid and flexbox for a clean, responsive layout with no external dependencies.
  • The stats bar at the top shows total candidates screened, how many earned an "interview" recommendation, and the highest score.
  • report_path.write_text(html) saves the finished HTML to the output/ folder.
  • webbrowser.open() tells your OS to open the file in your default browser automatically.

Why HTML instead of CSV?

A styled HTML file with color-coded badges and expandable sections is far easier to scan over coffee than a spreadsheet. Python's webbrowser module auto-opens it so you never need to find the file manually.

  • Save screen.py.
  • Confirm the full function imports cleanly by running:
python3 -c "from screen import generate_report; print('Report function ready')"

You should see Report function ready printed with no errors.

Update the main block and run the report

The current __main__ block prints a plain text summary. You need to replace that with a call to generate_report() so the full pipeline finishes by opening the HTML report.

  • At the bottom of screen.py, find these lines inside the if results: branch:
    if results:
        for r in results:
            print(f"{r.get('candidate_name', 'Unknown')}: {r['score']}/100")
  • Replace those three lines with a single call to generate_report(results), so the block now looks like this:
if __name__ == "__main__":
    job_description = load_job_description()
    results = screen_all_resumes(job_description)
    if results:
        generate_report(results)
    else:
        print("No resumes were successfully screened.")

What changed?

Instead of looping through results and printing text, the script now passes the entire sorted list to generate_report(). That function handles the HTML output and opens the browser in one call.

  • Save screen.py.

Before you run the script, what do you think will happen differently compared to last time? Think about what the new function does at the end.

  • Run the full pipeline by entering this command in your terminal:
python3 screen.py

Your terminal still prints the screening progress. Then your default browser opens with a styled HTML report showing candidates ranked by score, green/yellow/red badges, and expandable detail sections.

  • Confirm the output file exists by running:
ls output/

You should see a file named something like screening_report_2026-07-28_09-15.html (with today's date and time).

Report did not open in your browser?

  • Check that the output/ folder was created. If not, make sure OUTPUT_DIR = Path("output") is defined near the top of your file.
  • If the file exists but the browser did not open, try opening the HTML file manually by double-clicking it in Finder. Some terminal environments restrict webbrowser.open().
  • If you see a Python error about generate_report not being defined, make sure the function is above the if __name__ block and indented at the module level (no extra leading spaces on the def line).

Still stuck? Help me debug why my HTML report is not generating or opening.

✔️ Awesome, I've got everything!

Great. Double check you have saved screen.py and confirmed the report opens in your browser.

ⓧ I'd like to double check the full code

Your screen.py file should now have these sections in order. Scroll through your file and confirm each one is present:

  • Imports (os, sys, json, base64, webbrowser, Path, datetime, Anthropic, load_dotenv).
  • Client setup and constants (RESUMES_DIR, OUTPUT_DIR, JOB_DESC_FILE, MODEL).
  • load_job_description() function.
  • screen_resume() function with the scoring prompt and PDF document block.
  • screen_all_resumes() function with the loop and error handling.
  • generate_report() function containing score_color(), score_label(), the candidates loop, the HTML template, write_text(), and webbrowser.open().
  • if __name__ == "__main__": block calling generate_report(results) (not the old for-loop print).

Your recruiter now has a polished front-end. Next up, you will schedule it to run overnight so the report is waiting for you every morning.

Schedule It to Run Overnight with launchd

Your screener is working. It processes every PDF in the folder and opens a ranked HTML report in your browser.

But you still have to remember to run it yourself. macOS launchd lets you schedule the script to fire at 2:00 AM every night. If your laptop is asleep at that time, launchd runs the job the moment you open the lid in the morning.

In this step, get ready to:
  • Create a wrapper shell script that activates the virtual environment and runs the screener.
  • Create a launchd plist file that schedules the script at 2:00 AM.
  • Load and test the LaunchAgent to confirm it fires on demand.
Create the wrapper shell script

launchd cannot activate a Python virtual environment on its own. A short bash script handles the folder navigation, environment activation, and script execution so launchd only needs to call one file.

  • In Cursor, create a new file called run_screener.sh in the root of your recruiting-assistant folder.
  • Paste the following wrapper script into it:
#!/bin/bash
cd "$(dirname "$0")"
source .venv/bin/activate
python3 screen.py >> output/screener.log 2>&1

What does this script do?

  • #!/bin/bash tells macOS to run this file with bash.
  • cd "$(dirname "$0")" moves into the folder where the script lives, so all relative paths work correctly.
  • source .venv/bin/activate activates your virtual environment so the anthropic package is available.
  • python3 screen.py >> output/screener.log 2>&1 runs the screener and appends all output (including errors) to a log file.
  • Save run_screener.sh.
  • Switch back to your terminal and make the script executable by running:
chmod +x run_screener.sh
  • Confirm the file is executable by running:
ls -l run_screener.sh

You should see -rwxr-xr-x at the start of the line. The x characters confirm the file is executable.

Don't see the x permissions?

  • Make sure you ran chmod +x run_screener.sh from inside the recruiting-assistant folder.
  • Check for typos in the filename. It must be exactly run_screener.sh.

Help me figure out why my shell script permissions are wrong.

Create the launchd plist file

A plist file is an XML configuration that tells launchd what to run, when to run it, and how to handle logging. You will place this file where macOS looks for user-level scheduled jobs.

  • First, find your macOS username by running this in your terminal:
whoami

It prints your username (for example, janedoe). You will plug this value into the plist path below.

  • In Cursor, create a new file called com.recruiting.screener.plist in the root of your recruiting-assistant folder.
  • Paste the first half of the scheduling configuration:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.recruiting.screener</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/[[YOURUSERNAME="your macOS username from whoami"]]/recruiting-assistant/run_screener.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

What does this configure?

  • Label is a unique ID that macOS uses to track this scheduled job.
  • ProgramArguments tells launchd to run your wrapper script with bash. Replace the username placeholder with the value from whoami.
  • StartCalendarInterval sets the schedule to 2:00 AM every day.
  • Add the logging and environment configuration directly below what you just pasted:

    <key>StandardOutPath</key>
    <string>/tmp/recruiting-screener.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/recruiting-screener.log</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Why set PATH explicitly?

launchd does not read your shell configuration files. Its default PATH is only /usr/bin:/bin:/usr/sbin:/sbin. Without the extended PATH, it would not find the Homebrew-installed python3 on Apple Silicon Macs.

  • Save com.recruiting.screener.plist.
  • Copy the plist to the LaunchAgents folder by running:
cp com.recruiting.screener.plist ~/Library/LaunchAgents/
  • Validate the plist syntax by running:
plutil -lint ~/Library/LaunchAgents/com.recruiting.screener.plist

You should see the filename followed by : OK. This confirms the XML is well-formed.

Seeing a syntax error from plutil?

  • Open the plist in Cursor and check that every <key> has a matching value element (<string>, <integer>, or <dict>).
  • Confirm the file starts with the <?xml declaration and ends with </plist>.
  • Make sure you replaced the username placeholder with the actual output of whoami. No brackets or quotes should remain around it.

Help me fix my plist syntax error.

✔️ Awesome, I've got everything!

Great. Make sure you saved both run_screener.sh and com.recruiting.screener.plist and that plutil returned OK.

ⓧ I'd like to double check the full code

Scroll up and confirm you have both chunks in com.recruiting.screener.plist, in order:

  • Chunk 1: The XML header, Label, ProgramArguments, and StartCalendarInterval (ending at the closing </dict> for the calendar interval).
  • Chunk 2: StandardOutPath, StandardErrorPath, EnvironmentVariables with PATH, and the closing </dict> and </plist> tags.

And confirm run_screener.sh matches:

#!/bin/bash
cd "$(dirname "$0")"
source .venv/bin/activate
python3 screen.py >> output/screener.log 2>&1
Load and test the LaunchAgent

The plist is in place. Now you tell macOS to register it and then trigger a test run to prove the whole pipeline works end-to-end.

  • Load the agent by running:
launchctl load ~/Library/LaunchAgents/com.recruiting.screener.plist

Before you verify, what do you expect to see when you check for the agent in the list of loaded jobs?

  • Check that the agent is registered by running:
launchctl list | grep recruiting

You should see a line containing com.recruiting.screener. The first column shows a PID or - (not currently running), and the second column shows the last exit status.

  • Now trigger the job manually to test it. What do you think will happen when you fire the agent right now?
  • Run:
launchctl start com.recruiting.screener
  • Wait about 30 seconds for the API calls to complete, then check for a fresh report by running:
ls output/

You should see a new screening_report_ file with today's date and a recent timestamp in the filename. This proves the LaunchAgent found your script, activated the virtual environment, screened the resumes, and generated the report.

No new report appeared?

  • Check the log for errors by running cat /tmp/recruiting-screener.log.
  • If you see a "command not found" error for python3, confirm the PATH in your plist includes /opt/homebrew/bin.
  • If you see a "No such file or directory" error, double-check that the path in ProgramArguments matches the real location of run_screener.sh. Run pwd in your project folder and compare it to what you put in the plist.
  • After editing the plist, re-copy it to ~/Library/LaunchAgents/, unload with launchctl unload, then reload with launchctl load.

Help me debug why my LaunchAgent did not produce a report.

What happens when your laptop is asleep at 2 AM?

launchd remembers missed scheduled jobs. If your Mac is asleep at 2:00 AM, the screener runs automatically the next time you open your laptop. You will find a fresh report waiting for you.

Your recruiting assistant is now fully automated. Drop resumes into the folder during the day, and a ranked report is waiting for you by morning. Next up: the Secret Mission.

Secret mission

Add a Bias Check

Research shows AI screeners can produce wildly inconsistent scores based on resume formatting and writing style alone. Can you make your recruiter flag when its own scores swing between two different evaluation framings?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Anthropic API on a pay-per-use basis, so leaving the LaunchAgent running will consume a small amount of credit each night it screens resumes.

Cost warning

Your LaunchAgent runs every night at 2:00 AM and calls the Anthropic API for each resume in your folder. Each resume costs approximately $0.001-$0.003 to screen. If you leave resumes in the folder and the agent loaded, you will accumulate charges over time.

Monitor your remaining credit at console.anthropic.com. If you are done experimenting, choose Pause or Delete below.

Resources you used:

  • Anthropic API key with credit balance (usage-based, charges per resume screened).
  • macOS LaunchAgent at ~/Library/LaunchAgents/com.recruiting.screener.plist (free, runs locally).
  • Local project folder ~/Desktop/recruiting-assistant with Python virtual environment, scripts, and output reports.

Keep everything running

No action needed. Choose this if you are still actively using the recruiter and want it to screen new resumes every night.

  • Your LaunchAgent stays loaded and will fire at 2:00 AM each night.
  • Drop new PDF resumes into ~/Desktop/recruiting-assistant/resumes/ any time during the day.
  • Monitor your API credit balance at console.anthropic.com to avoid surprises.

Pause - I'll come back to this later

Unload the LaunchAgent so it stops running overnight. Your script, resumes, and reports stay in place for when you return.

  • Unload the LaunchAgent by running this command in Terminal:
launchctl unload ~/Library/LaunchAgents/com.recruiting.screener.plist
  • Your project files, resumes, and generated reports remain at ~/Desktop/recruiting-assistant/.
  • Your API key remains active but will not be used until you reload the agent.
  • To resume later, reload the agent by running:
launchctl load ~/Library/LaunchAgents/com.recruiting.screener.plist

Delete - I don't want to use this again

Remove all project resources completely. This unloads the scheduled agent, deletes local files, and optionally revokes your API key.

  • Unload the LaunchAgent by running:
launchctl unload ~/Library/LaunchAgents/com.recruiting.screener.plist
  • Delete the plist file by running:
rm ~/Library/LaunchAgents/com.recruiting.screener.plist
  • Delete the entire project folder by running:
rm -rf ~/Desktop/recruiting-assistant
  • To revoke your Anthropic API key, go to console.anthropic.com.
  • Open your API Keys page in the Anthropic Console settings.
  • Click the delete or disable option next to the key you created for this project.

Nice Work!

Nice Work!

You just built a fully automated recruiting assistant that screens PDF resumes with AI overnight and hands you a ranked report over coffee.

You've learned how to:

  • Built a Python script that sends PDF resumes to Claude as base64-encoded documents and parses structured JSON scores, strengths, and red flags.
  • Generated a polished HTML report with color-coded score badges, expandable detail sections, and automatic browser opening.
  • Automated the entire pipeline to run at 2 AM every night using a macOS launchd LaunchAgent that fires even if your laptop was asleep at the scheduled time.
  • Secret Mission: Added a dual-pass bias check that screens each resume with two different prompt framings and flags candidates whose scores differ by more than 15 points.

Ready to quiz yourself?