job-search-agent

Job Search Agent

A personal pipeline that finds early-career ML and AI engineering roles, ranks them against one résumé, and writes a tailored résumé, cover letter and form answers for the best matches. Its hard rule is that nothing may be claimed that the master résumé does not support. Every automated submission needs a recorded human approval.

I built it for my own job search with Claude Code. “Russ” in the prompts, the command help and docs/SUBMITTING.md is me, the candidate. Deterministic Python does the fetching, filtering, validation, gating and rendering. LLM agents (Claude Code workflows) make the judgment calls: scoring fit, drafting, and adversarially fact-checking the drafts.

How a daily run works

 fetch ─► prepare ─► score ─► skeptic ─► merge ─► pick ─► draft ─► fact-check ─► fix ─► re-check ─► build ─► digest
 (py)      (py)      (LLM)     (LLM)      (py)     (py)    (LLM)     (LLM)        (LLM)   (LLM)       (py)     (py)
                                                    │                             │
                                       live application form             check-app: schema,
                                       fetched from the ATS               form coverage, writing rules
                                                                          and the no-fabrication guard
  1. Fetch (fetch_jobs.py) pulls open postings from about 300 public job boards (302 of the entries in data/companies.json). The Greenhouse, Lever, Ashby and SmartRecruiters APIs list every posting, and Workday boards are searched by ML keywords. Postings are screened on title, seniority keywords and location. Every live URL is recorded, so a posting that disappears is treated as closed.
  2. Prepare (daily.py sweep runs fetch and prepare) drops postings already seen and clear mismatches: 5+ years required, eligibility requirements the candidate cannot meet, non-engineering titles, and postings older than max_posting_age_days. Descriptions are trimmed to the paragraphs that matter, so a batch of 20 fits in one agent’s context. At most max_batches_per_run batches are scored per day, newest first, which keeps each day’s compute bounded.
  3. Score (.claude/workflows/job-score.js). One agent per batch scores fit from 0 to 100 with a written rubric and cites résumé evidence. A second, deliberately skeptical agent re-reads every posting that scored high enough to be picked and tries to refute the score (hidden seniority, unmet must-haves, eligibility, location). Agent output is validated by daily.py check-scored and check-verdicts. Invalid files are skipped and their postings retried.
  4. Pick (daily.py pick) ranks by the post-skeptic score, applies caps (per company, per day, salary floor), confirms the posting is still live, and fetches the real application form (Greenhouse, Ashby and Lever) so answers are written for the questions actually asked. Questions only the human can answer are flagged. A form that requires a one-way video interview disqualifies the posting.
  5. Tailor (.claude/workflows/job-tailor.js): draft, adversarial fact-check, fix, then a final re-check by a fresh agent. The fix pass must apply every fabrication and overclaim finding and pass daily.py check-app. The re-check reads the file that was actually written. One targeted repair is allowed, after which it must be clean.
  6. Build (build_all.py) renders the résumé and cover letter to .docx and PDF through Microsoft Word, fits the résumé to one page, and writes a report per application.
  7. Digest (daily.py finalize) re-runs check-app --final on every drafted application, sets tracker statuses and writes a summary: what is ready, which questions need an answer, what failed. A failed draft can be picked again on a later day if it still ranks (two attempts at most).

How the human stays in the loop

Answers carry over between forms

New questions are the normal case, not the exception. Each answer is kept and reused:

Writing rules

Everything written in the candidate’s name follows his own writing rules (kept privately). style_guard.py enforces the mechanical ones inside check-app on the cover letter, the résumé summary and drafted form answers: no em dashes or semicolons, no banned words (“leverage”, “passionate”, “showcase”), no “not only X but also Y” or “It’s not X, it’s Y” constructions, no sentence over 40 words, and no wording that reads as inability (“I don’t have experience with…”). A skills question gets the closest real experience instead of a bare “No”. A Yes/No skills question with no defensible “Yes” goes to the human. The fact-checker covers the rest (metaphors, clichés, forced groups of three).

The no-fabrication guard

LLMs are good at making a résumé sound better than the person is. Two layers stop that:

Layout

Path What
daily.py the deterministic pipeline: sweep (= fetch + prepare), check-scored, check-verdicts, merge, pick, add, check-app, stamp-recheck, finalize, answer, learn, learn-question, questions, note, approve, can-submit, start-submit, mark, status, stats (daily.py --help lists them)
fetch_jobs.py ATS board clients and screening
forms.py live application-form fetcher (Greenhouse, Ashby, Lever), question classification, standing-answer matching
make_batches.py mismatch filter, description trimming, batch files
resume_guard.py the deterministic no-fabrication check
style_guard.py the mechanical half of the writing rules
render_resume.py, render_cover_letter.py, word_convert.py, build_all.py document rendering
.claude/workflows/ the LLM stages: job-daily, job-score, job-tailor, job-submit
config.json public defaults: applications per day, score thresholds, per-company cap, batch size, posting age. The salary floor is 0 (off) here. private/config.local.json overrides any key
data/companies.json the monitored job boards (302 fetched. A few careers sites without a supported API are kept with ats: other)
docs/SUBMITTING.md the browser playbook for the submit step
tests/ test_forms.py, test_style.py, test_workflows_ascii.py (plain scripts, each prints failures: 0) and integration_sandbox.py, which runs the deterministic pipeline end to end on a throwaway copy with the LLM stages stubbed

What stays private, and why

private/ and data/ (except data/companies.json) are git-ignored. They hold the candidate’s contact details, work-authorization answers, salary expectations, EEO answers and the record of where he applied. None of that belongs in a public repo. To run the pipeline you would create these yourself:

File What
private/resume_master.json the master résumé: contact, education, experience and projects with bullet ids, skills
private/profile.md target roles, locations, which eligibility requirements disqualify, known positioning risks
private/application_answers.md standard form answers: names, links, availability, salary rules, EEO
private/tailoring_rules.md rewording rules for this résumé and the name rules
private/writing_rules.md, private/answer_bank.md, private/knowledge.md, private/search_rules.md writing rules, sample answers, verified facts, where drafters may look
private/config.local.json overrides for config.json, the work-authorization policy (auth_policy) and the status wording

Running it

Windows only as written: PDF export drives Microsoft Word through COM (pywin32), and the workflows call .venv/Scripts/python.exe from Git Bash with the project path set at the top of each workflow script. It needs Python 3.12+ and Claude Code. The submit step also needs Chrome with the Claude in Chrome extension.

python -m venv .venv && .venv/Scripts/pip install python-docx pymupdf pywin32
.venv/Scripts/python.exe daily.py fetch      # fetch and screen every board into data/runs/<today>/ (no LLM, no private files)
.venv/Scripts/python.exe daily.py status     # tracker summary
for t in tests/test_*.py; do .venv/Scripts/python.exe "$t"; done   # each prints failures: 0

The full daily run is the job-daily workflow, scheduled once a day in Claude Code. It needs the private files above. tests/integration_sandbox.py also needs them and at least one earlier daily.py fetch.