All posts
AI & ML

I Built an AI Job-Hunt Pipeline That Rejects Almost Every Job

career-ops is an open-source pipeline I built to scan, score and tailor applications across hundreds of postings. The real engineering wasn't the AI -- it was a zero-token scanner, an SSRF allowlist, and one rule that stops it blacklisting live jobs.

Dhileep Kumar8 min read
I Built an AI Job-Hunt Pipeline That Rejects Almost Every Job

The pitch writes itself: a bot that fires off a hundred job applications while you sleep, and you wake up to a calendar full of interviews. I built most of that machine. Then I deliberately left out the part that fires. The project is called career-ops -- an open-source job-search command center that turns any AI coding CLI (Claude Code, Gemini, OpenCode, Codex) into a pipeline that evaluates postings, scans company career pages, and generates tailored CVs. And the more of it I built, the clearer it got that the valuable part was never the applying. It was the rejecting. The automation earned its keep when it was talking me out of a job, not into it.

A job search is just a data pipeline

Strip away the anxiety and a job hunt is a pipeline with a few boring stages: find the openings, read each one, decide whether it's worth your time, tailor your materials, reach an actual human, follow up. Then repeat a couple hundred times. Every stage is mechanical and cheap to check -- exactly the profile of work software is good at and people are terrible at doing at volume without burning out. So I built it as a pipeline, with one hard design rule: the high-volume parts run as plain Node scripts and cost zero model tokens; the LLM only gets called for the handful of postings that survive the cheap filters. The whole repo has no database and no build step -- the data layer is just Markdown, YAML and TSV files on disk, which turned out to matter more than I expected.

The scanner that costs nothing to run

The first stage -- pulling fresh postings -- is the one people usually reach for a scraper and an LLM to do. That's backwards. Company career pages almost all sit on a handful of applicant-tracking systems (Greenhouse, Lever, Ashby, Workable, Workday, and a few more), and every one of those exposes a public JSON API. So the scanner (scan. mjs) just hits those endpoints directly. Pure HTTP, pure JSON, zero Claude tokens. It dedupes new URLs against a TSV history file and drops the survivors into an inbox for evaluation.

The part I'm actually proud of is the architecture, because it made the tool boring to extend. Each ATS integration is a self-contained module in providers/ that exports the same tiny contract, and they load in alphabetical order so detect() priority is deterministic across machines. Adding a whole new job board is literally dropping one . mjs file into that directory:

javascript
// scan.mjs -- zero-token portal scanner with a plugin-based provider layer.
//
// Providers live in providers/*.mjs and are loaded at startup. Each provider
// exports a default object with:
//   - id: string        matched against provider: in portals.yml
//   - detect(entry): {url}|null   optional auto-detection from careers_url
//   - fetch(entry, ctx): [{title,url,company,location}]   required
//
// Zero Claude API tokens -- pure HTTP + JSON.

There are ten of these providers shipped today (ashby, greenhouse, lever, workable, workday, smartrecruiters, recruitee, and friends), and files prefixed with an underscore are shared helpers that never get loaded as providers. When I want a new board, I don't touch the scanner -- I write one detect/fetch pair and it appears.

The unglamorous security work nobody sees

Here's the part the 'AI applies to jobs for you' fantasy never mentions: the moment your tool fetches arbitrary URLs on someone's behalf, you've built an SSRF vector, and the moment it reads live web pages you inherit every anti-bot wall and stale feed on the internet. Most of my actual engineering time went here, not into prompts. The Greenhouse provider is a good example. Its public API URL could be abused to make the server fetch somewhere it shouldn't, so it carries an allowlist of exactly four permitted hostnames, rejects any non-HTTPS URL, and -- the part that's easy to forget -- fetches with redirect set to 'error' so a server-side redirect can't quietly bounce the request off the allowlist after the check passes. The allowlist and the redirect guard only work as a pair.

The liveness checker taught me the subtler lesson. When you ask it to verify a posting is still live, it launches headless Chromium and reads the page -- and on some portals (pracuj. pl was the one that bit me) Chromium trips a Cloudflare or hCaptcha wall. The challenge page is tiny, so my content-length heuristic read it as an empty, dead posting, marked the job expired, and wrote it to the scan-history file -- which permanently filters that job out of future scans. A live job, silently blacklisted forever. The fix was to detect the anti-bot interstitial before the length heuristic ever runs and return 'uncertain' instead of 'expired':

javascript
// Anti-bot interstitials (Cloudflare "Just a moment...", hCaptcha walls) render a
// tiny challenge page instead of the posting. They must NOT be read as expired:
// without this guard they fall through to insufficient_content -> expired, and
// scan --verify would write live jobs to scan-history and permanently filter them out.
if (botChallenge) {
  return {
    result: 'uncertain',
    code: 'bot_challenge',
    reason: 'anti-bot challenge: ' + botChallenge.source,
  };
}

A related rule in the same file: a nav or footer 'Apply' link must never make a dead posting look alive. Expired signals win over generic Apply text. These are a handful of lines of code each, and they are the difference between a tool you can trust to run unattended and one that quietly corrupts its own memory.

The agent is never trusted because it's clever. It's trusted because I can catch it being wrong before anything leaves my machine.

The number that isn't the point

Only now does the AI get involved, and only for postings that made it through the free filters. Each one gets a structured evaluation: the model classifies the role into one of six archetypes, reasons about my actual cv. md against the job description, and produces a 1-to-5 weighted score across fit, comp-versus-market, cultural signals and red flags. Crucially, it also runs a separate 'posting legitimacy' pass -- a ghost-job and scam detector that grades a listing High Confidence / Proceed with Caution / Suspicious and deliberately does not touch the numeric score, because a real-but-risky job and a fake-but-appealing one are different problems.

The scoring bands are where the whole philosophy lives, and they are hard-coded:

  • 4.5 and up: apply, this one is worth the effort
  • 4.0 to 4.4: worth applying
  • 3.5 to 3.9: only with a specific reason you can name
  • Below 3.5: the system recommends against applying, on purpose

That last line is the product. The scanner reads far more postings than I'd ever apply to, and it is designed to kill almost all of them. The expensive resource in a job search was never the writing -- it was attention: working out which handful of openings actually deserved a serious, customized, human effort. Automating the funnel was never about applying to more jobs. It was about earning the right to apply to fewer.

Why a human still clicks submit

The system stops before it submits. Every application waits for me to read it and press the button. People ask why I don't just let it rip, and there are two honest answers. The first is that it doesn't work: mass-applying with generic materials is the exact behavior every ATS and recruiter is now tuned to filter out, and every application a human eventually reads costs someone's attention. The second matters more. The last five percent of the decision -- is this actually a fit, is this somewhere I'd be glad to work, is this draft even true -- is precisely the part a model fakes most convincingly and gets wrong. Generating is cheap; that's exactly why the approval gate has to stay expensive.

I even distrust the agent inside my own tooling. It's forbidden from editing the canonical tracker directly -- if it could append rows to the applications file, parallel workers would race and produce duplicates. So agents write a TSV file and a separate merge script owns the merge, dedup, and column normalization. The AI proposes; deterministic code disposes.

If you build your own

The pattern generalizes to any tedious, high-volume search. Here's the version of the playbook I wish I'd had on day one:

  1. Do the cheap filtering with plain code, not the model. Hit APIs directly and spend zero tokens ranking; only pay for the LLM on the survivors.
  2. Verify the posting is alive before anything else -- and treat anti-bot walls as 'uncertain', never as 'dead', or you'll blacklist live jobs.
  3. Score before you tailor. Rank ruthlessly first; only invest in customization for roles that clear the bar.
  4. Keep exactly one human gate, right before submission, and never automate past it.
  5. Never let the agent write to your source of truth directly. Have it emit a proposal and let deterministic code do the merge.

That's the quiet truth under most 'automate everything' stories. The win is rarely doing more of the thing. It's building a machine ruthless enough -- and honest enough about its own failure modes -- to let you do far less of it, on purpose. I use AI to scan hundreds of jobs. It just spends almost all of that automation talking me out of them, and saves everything I've got for the few worth showing up for.

Share

Enjoyed this?

Get the next deep dive in your inbox. No spam — just the stories worth reading.

Subscribe to the newsletter

Comments