Henry Robinson.Back

AI · Project

Career Ops, an open-source job-search pipeline I priced and gave a front end

Career Ops started as somebody else's open-source project: an agent-driven pipeline that scans hiring portals, scores the roles it finds against your CV, and writes tailored applications. I forked it because the unglamorous eighty percent was already solved and maintained by people who see far more hiring portals than I ever will. What it did not have was the two things I actually needed.

The first was money. A pipeline that scores fit but says nothing about pay will happily recommend a role that is a beautiful match and a thirty percent cut. So I wrote a compensation engine and wired it in, and now every evaluated posting carries an estimated total, its confidence, and a status explaining itself when it cannot produce a number honestly. The second was a place to look at the results. A nightly run that leaves its output in a folder is a run nobody reads, so the pipeline now reports into a console with a sortable table and a step-through review flow for the overnight batch.

The third thing I added, which I did not plan for, was a second person. My partner runs the same system against a different set of targets, which forced the fork from single-user to multi-user without diverging so far from upstream that I could no longer take its upgrades.

~120 companiesscanned nightly across six applicant-tracking systems
4,000+ runspostings evaluated end to end
Two usersone system, separate CVs, filters, and data

Five stages, left to right: where postings come from, what survives the filters, what the agent does with each one, where it all lands, and what I look at in the morning. Click any box to see what it does.

Sources · public ATS

  • Greenhouse
  • Lever
  • Ashby
  • Workday
  • USAJOBS
  • NEOGOV

Scan + filter · nightly

  • Portal scan
  • Filters
  • Drain queue

Evaluate · agent CLI

  • Fit scoring
  • Comp engine
  • Tailored documents
  • Voice pass

Data

  • Neon Postgres
  • Blob storage

Console · Vercel

  • Results table
  • Review flow
  • Config

Click any box to see what it does. Drag the canvas to pan; the page scrolls normally. Rust arrows trace the main flow. Thin grey lines are everything around it, and dashed grey lines are supporting config and platform.

A cron wakes the scanner at 10pm Pacific. Everything up to the evaluation is deterministic code with no model in the loop, because a model call per posting across roughly 120 companies would be both the entire cost of the system and its least reliable part.

Rendering diagram…

This is the part I get asked about, and the part I would defend hardest. The engine is a deliberate refusal to play the game the question invites.

It refuses to answer the obvious question

The engine never asks “what is this role worth?”, because that is a number anyone can argue with. It asks whether the role beats the best alternative I could already get, plus a premium for taking on the risk of a smaller company.

The baseline is a knowable number

The best alternative is a big-company role at my level. That is a real, checkable figure, and it anchors everything else. Cash compares to it directly.

Equity is never assigned a value

Instead the engine works backwards. The cash falls short of the baseline by some gap, and the shares are priced at the company’s own last round. So how much must the company grow for the equity to close that gap? That required growth is then compared against what companies at that stage actually do, from a base-rate table. Required growth far above the base rate means I would be paying to take their risk.

Two modes, one engine

While searching, a posting rarely states equity, so the output is a clearly labelled market estimate whose only job is to rank a pipeline. Once there is a real grant on the table, the estimate is dropped and the real verdict runs: lowball, fair, or highball, with the specific ask that would close the gap.

Confidence is the weakest link, not the average

Level, baseline, and equity each carry their own confidence, and the overall confidence is the lowest of the three. Averaging them would let two strong inputs hide one guess.

A posted band always wins, if it is plausible

When an employer publishes a salary range, its midpoint overrides the model. But an hourly band would otherwise feed the engine a $50 base and produce a confident near-zero total, so a band is only trusted as a base when it reads like an annual figure. The raw band string is stored either way.

A fork has a different set of constraints from a green-field build. Most of these calls are about staying compatible with an upstream I do not control, while adding things it was never going to have.

Fork, do not rebuild

Start from an open-source job-search pipeline rather than writing one, and keep taking upstream releases.

WhyThe unglamorous 80% — applicant-tracking-system parsers, deduplication, the tracker format, document generation — was already solved and is maintained by people who see far more hiring portals than I do. My time was better spent on what was missing.

Trade-offEvery enhancement I make has to be replayed on each upgrade. That recurring cost is exactly why my changes stay surgical instead of sprawling.

Multi-user by path, not by rewrite

One environment variable selects whose CV, filters, and tracked companies a run uses. The repo holds the system; a folder outside it holds each person’s data. Unset, it behaves exactly like the single-user original.

WhyMy partner needed the same pipeline pointed at different targets. A fork that diverges structurally from upstream cannot take upgrades, so the change had to be additive.

Trade-offEvery per-user path has to resolve through that variable, and getting one wrong would silently mix two people’s data. The startup check resolves the user directory first and fails loudly if it is missing.

Two run contexts, one pipeline

The same steps run interactively with a human in the chat and headless under the control plane. In the headless path, any gate that would have waited for input records its outcome and moves on.

WhyAn unattended nightly run that stops to ask a question is not paused. It is a job that failed slowly, and I find out in the morning.

Trade-offDecisions that genuinely need judgment have to be re-surfaced in the UI later instead of being resolved in the moment.

Compensation is displayed, never scored

The estimate is a sortable column, and it is explicitly excluded from the fit score.

WhyIf pay feeds the score, the pipeline starts recommending well-paid roles I do not want. The score answers “is this a fit”. The comp answers “is it worth it”. Collapsing them into one number destroys both.

Trade-offI read two numbers instead of sorting by one.

Never invent a number

When the engine cannot price a posting it returns nulls plus a status saying why — no profile, no data, or a real error — and the row shows no estimate.

WhyAn estimator that guesses when it does not know is worse than no estimator, because you can no longer tell the guesses from the reads.

Trade-offPlenty of rows show no compensation until the cache has data for that company. An empty cell is the honest answer.

Crash-proof the wrapper, not the engine

The comp step exits non-zero only when I have called it wrong. Every data outcome exits cleanly with a status carrying the truth.

WhyCompensation is one field on an evaluation. A pricing failure must never fail the evaluation around it, or one bad company kills the night’s run.

Trade-offA genuine engine bug can hide behind a status code, so schema drift is logged loudly to standard error rather than swallowed.

The voice pass runs downstream

The cover letter is drafted, the session ends, and only then is the letter rewritten through a separate voice pass.

WhyThe drafting model optimizes for covering the job description. Voice is a different objective, and asking for both at once means voice loses every time.

Trade-offAnother step that can fail, plus a backfill script for letters written before the pass existed.

Deterministic where it can be, agentic where it must be

Scanning, filtering, deduplication, freshness, and document rendering are plain code. The model is used only where judgment is genuinely required: reading a job description against a CV, and writing.

WhyA model call per posting across roughly 120 companies every night would be the whole cost of the system, and it would be the least reliable part of it.

Trade-offThe deterministic layers need a real parser per portal, and a portal that changes its markup breaks loudly until I fix it.

The pipeline is plain Node with no framework. The console is the same Next.js dashboard that runs my agents.

Node.js (ESM)·Claude Code CLI·Kimi k3·Playwright·Camoufox·Greenhouse · Lever · Ashby · Workday · USAJOBS · NEOGOV·LaTeX·DOCX·Neon Postgres·Vercel Blob·Next.js 16

The pipeline is a fork of the open-source career-ops project, tracking upstream releases. The compensation engine and the console are mine. Nothing of mine has merged upstream. Two of my extensions are open there as plugin candidates, one issue is closed, and one pull request was closed without merging. They all ship in my fork in the meantime.