Filtering 740 Job Listings and Auto-Generating 100 Tailored CVs

If you're still managing your job search in a spreadsheet, you're already behind. This post walks through a multi-agent pipeline — built with Claude Code and a project called career-ops — that scored 740 job postings from A to F, then auto-generated 100 customized CVs for the ones worth applying to.

The idea isn't to apply everywhere. It's to let the machine do the grunt filtering so you only touch the opportunities that actually match.

overall pipeline flow


The Problem: Spreadsheet Job Hunting Doesn't Scale

The default job search workflow looks something like this: open LinkedIn, scroll, copy a job title into a spreadsheet, paste the URL, manually write notes, repeat for three hours, feel exhausted, apply to two jobs.

The math doesn't work. If you need to send 50-100 applications to get statistically meaningful signal back, doing each one manually is a full-time job on top of your actual full-time job search.

The first thing I tried was just dumping job listings into a CSV and writing a Python script to keyword-match against my resume. It broke immediately — keyword matching is garbage. A posting that says "3+ years of production Kubernetes experience" and one that says "familiarity with container orchestration" mean roughly the same thing to a human, but not to str.contains("kubernetes").

naive keyword match failure

What I needed was semantic understanding of fit, not string matching.


The Fix: A-to-F Scoring with a Multi-Agent Pipeline

The core insight is that job-fit scoring is a reasoning task, not a search task. You don't grep for fit — you evaluate it. That's exactly what a language model does well.

The career-ops pipeline runs two distinct agents in sequence:

  1. Scoring Agent — reads each job description against a candidate profile and assigns a letter grade (A through F) with a short rationale.
  2. CV Generator Agent — for any posting graded A or B, it generates a tailored CV that reframes the candidate's experience to match the specific language and priorities of that posting.

Step 1: Define the Candidate Profile

Start with a structured YAML profile. This is what both agents reference.

# candidate_profile.yaml
name: Seunghyeon
target_roles:
  - Backend Engineer
  - Platform Engineer
  - DevOps / SRE
core_skills:
  - Python
  - PostgreSQL
  - Docker / Kubernetes
  - AWS (ECS, RDS, Lambda)
  - REST API design
experience_years: 5
preferred_company_size: [startup, series-a, series-b]
avoid:
  - pure frontend roles
  - enterprise sales tooling
  - no-code platforms

Step 2: Score the Job Listings

The scoring agent receives the candidate profile and a batch of job descriptions. Each call returns a grade and a one-sentence reason.

# score_jobs.py
import anthropic
import yaml
import json

client = anthropic.Anthropic()

def score_job(candidate_profile: dict, job_description: str) -> dict:
    prompt = f"""
You are evaluating job fit for a software engineer.

Candidate Profile:
{yaml.dump(candidate_profile)}

Job Description:
{job_description}

Grade this job from A to F based on fit:
- A: Strong match, apply immediately
- B: Good match, worth customizing CV
- C: Partial match, significant gaps
- D: Weak match, major misalignment
- F: No match or explicitly excluded criteria

Return JSON only: {{"grade": "B", "reason": "one sentence"}}
"""
    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=256,
        messages=[{"role": "user", "content": prompt}]
    )
    return json.loads(response.content[0].text)

# Run across all listings
with open("candidate_profile.yaml") as f:
    profile = yaml.safe_load(f)

with open("job_listings.jsonl") as f:
    jobs = [json.loads(line) for line in f]

results = []
for job in jobs:
    score = score_job(profile, job["description"])
    results.append({**job,**score})

# Filter to A and B
shortlist = [r for r in results if r["grade"] in ("A", "B")]
print(f"Shortlisted {len(shortlist)} / {len(results)} postings")

Running this against 740 postings took about 18 minutes on claude-opus-4-5. Cost was roughly $4.20 total — scoring 740 jobs for the price of a coffee.

Expected output:

Shortlisted 98 / 740 postings

Step 3: Generate Tailored CVs

For each shortlisted job, the CV generator agent takes your base resume and rewrites the summary and bullet points to mirror the specific language in that posting — without fabricating experience.

# generate_cvs.py
def generate_tailored_cv(base_resume: str, job: dict) -> str:
    prompt = f"""
You are a professional resume writer.

Base Resume:
{base_resume}

Target Job Title: {job['title']}
Company: {job['company']}
Job Description:
{job['description']}

Rewrite the resume summary and top 3 bullet points per role to match
the language and priorities of this job description.

Rules:
- Do NOT invent experience or skills not in the base resume
- Mirror the exact terminology the job uses (e.g., if they say "observability"
  not "monitoring", use "observability")
- Keep total length under 1 page
- Return the full resume as plain text

Output the resume only, no preamble.
"""
    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1500,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text

with open("base_resume.txt") as f:
    base_resume = f.read()

for i, job in enumerate(shortlist):
    cv = generate_tailored_cv(base_resume, job)
    filename = f"cv_output/{i:03d}_{job['company'].replace(' ', '_')}.txt"
    with open(filename, "w") as f:
        f.write(cv)
    print(f"Generated: {filename}")

CV generation per shortlisted job


Variations and Gotchas

Batching API calls for speed

Scoring 740 jobs sequentially is slow. Run them in concurrent batches using asyncio and the async Anthropic client:

import asyncio
from anthropic import AsyncAnthropic

async_client = AsyncAnthropic()

async def score_job_async(profile, job):
    response = await async_client.messages.create(
        model="claude-opus-4-5",
        max_tokens=256,
        messages=[{"role": "user", "content": build_prompt(profile, job)}]
    )
    return json.loads(response.content[0].text)

async def score_all(profile, jobs):
    semaphore = asyncio.Semaphore(10)  # max 10 concurrent calls
    async def bounded(job):
        async with semaphore:
            return await score_job_async(profile, job)
    return await asyncio.gather(*[bounded(j) for j in jobs])

This cuts 740-job scoring from ~18 minutes to under 3 minutes.

Model choice matters per stage

Use heavier models for scoring (you want reasoning quality), lighter models for CV generation (it's mostly reformatting):

Stage Model Why
Scoring (A-F grade) claude-opus-4-5 Nuancedfit judgment
CV generation claude-sonnet-4-5 Fast, sufficient for rewrite
Dedup / cleanup claude-haiku-4-5 Cheap, mechanical task

The JSON parsing trap

The scoring agent returns JSON, but models occasionally add a preamble like "Here is the evaluation:". Wrap your parse in a fallback:

import re

def safe_parse(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        # Extract JSON object from anywhere in the text
        match = re.search(r'\{.*?\}', text, re.DOTALL)
        if match:
            return json.loads(match.group())
        raise ValueError(f"No parseable JSON in: {text[:200]}")

Environment differences

Environment Gotcha
Mac (local) File path limits fine; watch out for open() encoding on non-ASCII company names
Linux / CI Set ANTHROPIC_API_KEY in your .env; don't hardcode
Docker Mount cv_output/ as a volume or you'll lose everything on container exit

environment deployment options


Closing

The shift this pipeline forces is worth naming clearly: instead of you being filtered by companies, you're filtering companies. The AI does the low-signal work of reading 740 job descriptions — you spend your time on the 100 that actually fit.

Next step from here is wiring in an email agent that drafts cover letters for grade-A postings and stages them in Gmail drafts, ready to send with one click. That part is almost done.


🐦 Faster updates on X: @baegseungh7061
📚 More in this series: All posts
💌 Subscribe: Follow on X or grab the RSS

댓글