Skip to content
← All projects

Case study

APPLYOURSELF

A job-search pipeline that runs on your own machine. It finds and scores roles, tailors each resume from your own words, and fills the application.

rephrased lines across 363 resumes, 1 unattributed
741
resumes that hit a banned phrase
7 / 510
tests over the deterministic layer
4,507

The problem

Applying by hand across several kinds of roles doesn't scale. The tools that automate it either send generic applications or write free text that invents experience, and none of them check visa sponsorship. I wanted automation that only claims what I've already written myself.

How it works

  1. 01

    Discover

    Nightly scrape of LinkedIn and Indeed (JobSpy), Greenhouse, Lever, Ashby, and Workday. Then normalize, drop stale and out-of-area posts, fuzzy-dedupe, and give each job a stable content-hash ID.

  2. 02

    Screen

    Rules drop out-of-lane titles, roles that rule out sponsorship, and roles over the lane's experience cap.

  3. 03

    Score

    Parallel Claude Sonnet judges, one per 100-row range, score title, skills, seniority, and domain out of 100. Top 25 per lane make the shortlist.

  4. 04

    Tailor

    Claude Opus keeps, rephrases, or drops bullets from your own bullets.md, using pre-approved synonyms only, and logs a source for every line.

  5. 05

    Lint and apply

    Two-tier linter, up to 5 rewrites, then refuse. Playwright fills Greenhouse, Lever, and Ashby forms, and parks any role with a field it can't answer from config.

Results

There's no formal eval yet. These are counts from the pipeline's own logs and state. Outcomes after applying aren't tracked yet.

7 of 510 can read like the linter does nothing. It's a backstop, not the main filter: the model is told the banned list up front, and the linter catches it when it slips.

Decisions and dead ends

Models judge, code decides

No code under src/ calls an LLM, and a hook blocks LLM imports there. Every model decision is written to an artifact next to the resume, so the same inputs give the same output.

The coordinator only sees counts

Each judge reads only its own 100-row range and writes its own batch file. The session that spawns them gets counts back, never job text or scores, so it can't quietly start judging rows itself. A coverage gate re-runs missing ranges, and partial coverage never merges.

Refuse, don't ship with a note

A flagged phrase never goes out with a warning attached. The only exits are a clean lint or a refusal that writes nothing and names the line that failed.

Unknown is not dead

A stale status snapshot retired 7 live Ashby boards carrying about 186 postings. Now a fresh probe runs before any board is retired, and REST 404s are checked against Ashby's GraphQL before a board counts as gone.

Personal data stays local

Profile files are gitignored. A PII denylist scan runs on commit and push, including history, messages, and author emails. /apply drives a separate, empty Chrome profile.

Stack

Python · uv · Claude Code · Claude Sonnet · Claude Opus · pandas · pyarrow · JobSpy · rapidfuzz · libpostal · python-docx · Playwright · pytest · GitHub Actions · launchd

← Back to all projects