Skip to main content

Command Palette

Search for a command to run...

Applying to jobs is a signal problem, not a discovery problem

Building karani, part 1: what 141 hourly passes over 2,211 postings taught me about filtering, and why the pipeline never presses submit.

Updated
13 min readView as Markdown
Applying to jobs is a signal problem, not a discovery problem
K

Senior Software Engineer with a knack for Python, Golang, TypeScript, and Elixir. I am also a bit of a Rust enthusiast. I am excited by all things scalability and microservices. Join me on this journey to becoming a unicorn 10x Engineer.

I was spending 45 minutes a day reading job boards, and the roles I actually wanted were under 1% of what those boards showed me. That sentence is the first thing in docs/vision.md of karani, the job application pipeline I started on 27 August 2026 and have been running hourly ever since, apart from eight days in September when it was quietly dead. A typical hourly pass now fetches about 2,200 postings. Around 273 survive the first filter. Fifteen reach a language model. A few a day come back as drafted application packs, and I press submit myself.

This is part 1 of twelve posts about building that pipeline. The promise for this one: by the end you'll know why I think the job hunt is a filtering problem rather than a finding problem, what that belief cost me in design decisions, and which of those decisions I'd expect to be wrong.

What's below:

  • the advice everyone gives, and the assumption buried in it
  • what 141 hourly passes say about where the volume actually dies
  • the two bets karani is built on, and the four axes every filter traces back to
  • why the pipeline stops short of pressing submit
  • what would change my mind, and what the other eleven parts cover

The standard job-hunt advice is a discovery strategy

Ask anyone how to find a better job and you get variations on one idea: see more openings. Set up more alerts. Follow more companies. Refresh more boards. Message more people so you hear about roles before they're posted. The whole apparatus of career advice, from the LinkedIn hustle posts to the paid coaches, is organised around increasing the number of openings that pass in front of your eyes.

That advice has an assumption inside it. It assumes the openings you want exist somewhere you aren't looking, and that the bottleneck is access.

For some people that's true. If you're changing field, or you're early enough in your career that you don't know what the roles are called, more exposure genuinely helps. It was not true for me. I'm in Nairobi, I've been shipping backend and ML systems for eight years, and I know exactly what I'm looking for: fully remote with no country restriction, paid on a San Francisco band rather than a local one, senior or staff, engineering rather than management. Every one of those is a filter, not a search term.

The numbers say discovery was never the bottleneck

karani pulls from nine sources. Four are ATS families, queried per company slug: Greenhouse, Lever, Ashby, Workable. Four are global remote boards: RemoteOK, Himalayas, Remotive, We Work Remotely. The ninth is aijobs.net. No LinkedIn, no Indeed, because scraping either is hostile to their terms and brittle in practice. Fetching is bounded rather than polite-by-accident: three concurrent requests per host, six across the run, with retries on 5xx and 429.

I parsed every ingest summary the hourly job has written since 27 August. 141 passes, and they are boring in a useful way:

ingest={'fetched': 2185, 'inserted':  0, 'passed_prefilter': 283, 'source_errors': 18}
ingest={'fetched': 2185, 'inserted': 20, 'passed_prefilter': 283, 'source_errors': 18}
ingest={'fetched': 2205, 'inserted': 20, 'passed_prefilter': 283, 'source_errors': 18}
ingest={'fetched': 2202, 'inserted': 22, 'passed_prefilter': 283, 'source_errors': 18}
ingest={'fetched': 2044, 'inserted':  0, 'passed_prefilter': 265, 'source_errors': 20}

Five consecutive ingest summary lines from the hourly log, each showing about 2,200 postings fetched, roughly 283 passing the pre-filter, and 0 to 22 new rows inserted.

The first five passes in logs/hourly-20260914.log. inserted counts rows that are new or changed, which is why it drops to zero on an hour when nothing moved.

Median pass: 2,211 fetched, 273 through the deterministic pre-filter, 20 genuinely new rows. That's 12.35% survival, and the interesting part is the variance, or the lack of it. 135 of those 141 passes put between 269 and 285 rows through the pre-filter.

Line chart of 141 hourly passes on a log axis. Postings fetched runs flat near 2,200 and rows passing the pre-filter runs flat near 273, with a few sharp dips where the pipeline was unhealthy.

141 passes parsed from logs/hourly-*.log. Both lines are flat, which is the point. The five dips are partial passes. The eight days in September when karani ran nothing at all don't show up here as a dip, because a pass that never runs writes no line; that outage gets its own post later in the series.

Nine sources, running hourly, and the same 273 rows keep coming back because the boards themselves are mostly static. Adding a tenth source would raise the top bar. I doubt it would change how many roles I can honestly apply to in a week, which has never been more than a handful, though I haven't run that experiment and the numbers above can't settle it. I was applying to five roles a week before any of this existed and getting roughly one screen out of it.

There's a sweep step too: anything not seen for 10 days gets marked closed, so I don't waste a morning writing to a req that quietly closed weeks ago.

So the volume is there. The volume was always there. What I didn't have was a cheap way to tell 2,211 rows apart.

Signal detection is the right frame, and it has different maths

Here's where the two worlds I sit between disagree, and it's worth being precise about it.

The career-advice view optimises recall. Cast wide, see everything, don't miss the one. Its failure mode is invisible: the opening you never saw.

The engineering view, the one I reach for because it's how I've spent eight years thinking, optimises precision under a budget. That's a preference, not a theorem, and it comes from how the work actually goes: when the thing you want is under 1% of what you see, a filter that says "maybe" too often costs you more than one that occasionally says no to something good. Every false positive eats the scarce resource, and the scarce resource in a job hunt isn't postings. It's the hours you can spend writing something a human will actually read.

That framing changes what you build. You stop asking "how do I see more?" and start asking "what's the cheapest test that removes the most rows, and what does each test cost me in missed roles?"

Both views are right about something. The advice crowd is right that you need to be in the flow of openings at all, and a filter over nothing filters nothing. The engineering view is right that once you're in the flow, adding more inflow without a filter makes your week worse, not better. Every extra source is more rows to read at 6am.

Running an LLM on every posting costs about $100 a week and buys almost nothing

The obvious way to build this in 2026 is to hand every posting to a model and ask whether I fit. I wrote that cost model out in ADR 0001 before writing the code: roughly 10,000 postings a week at about a cent each is $100 a week, spent almost entirely on confidently negative verdicts about sales roles in Austin.

So karani runs three tiers instead.

Horizontal bar chart on a log axis: 2,211 postings fetched per hourly pass for free, 273 surviving the free deterministic pre-filter, 15 sent to the LLM at about a cent a row, and a note that 56 application packs were drafted across nine days and none were sent automatically.

Medians over the same 141 passes. The log axis is doing a lot of work here: each tier is an order of magnitude cheaper to be wrong about than the next one.

Tier one is deterministic and free. karani/ingestion/filters.py is 286 lines of word-boundary signal matching over role category, seniority band, geographic restriction, remote status, pay-parity language and skill overlap. Everything it drops leaves a written reason behind in PreFilterResult.reasons_failed, so when the pipeline throws away something it shouldn't have, I can read why instead of interrogating a model about its mood.

Tier two is a single LLM call per surviving row, around a cent, producing a structured verdict: a fit score from 0 to 100, evidence-backed strengths, gaps with mitigations, red flags. karani.toml currently caps this at qualify_limit = 15 per pass.

Tier three is an agent with tools, somewhere between 5 and 15 cents a row, and it only runs on candidates that already scored well. It's off by default, which is ADR 0007, and I'll come back to why in a later part.

ADR 0001 says the deterministic tier is meant to drop about 95% of postings. The logs say 12.35% of a median pass survives it, which sounds like a contradiction until you notice the denominators differ: the log number counts every row fetched including the hundreds that repeat every hour, while the design target was about postings the pipeline sees for the first time. I've kept both numbers rather than picking the flattering one. Part 2 gets into the tier split properly, with the content-hash work that makes the repeats cheap.

Positioning is a filter you can run, not a paragraph you write

The thing I didn't expect: writing this forced me to make my positioning executable.

ADR 0002 is the record of getting it wrong first. Version 0.1 filtered for "Kenya-eligible" roles, with signal sets literally named kenya_positive_signals and kenya_negative_signals. Two failure modes showed up almost immediately. Any job description containing "global brand" or "worldwide team" fired the positive signal, and most of those roles turn out to be locked to one region. Meanwhile the companies that genuinely do hire from anywhere never mention Kenya or Africa at all. They say "we hire from anywhere" and leave it there.

The fix was to stop encoding my location and start encoding the market segment. The signals became global_hire_positive_signals, regional_restriction_signals and pay_parity_signals. The comp floor went from $140k to $160k, because below that isn't an SF band. My being in Nairobi became a downstream consequence of "this company hires globally" rather than an input to the filter.

That refactor has a cost I wrote into the ADR at the time: Africa-native companies score lower now, because their bands are regional by design. They're still in the target list for optionality. I'm not pretending the trade-off is free.

Underneath all of it are four axes, and every filter in the codebase traces back to one of them:

  • Global-remote or region-locked. Region-locked is a veto, unless the posting sponsors relocation, in which case it goes to the model as "unclear" instead of being dropped.
  • SF-band or location-adjusted. Location-adjusted costs fit score but doesn't veto, because the signal is usually just absent from the description. Below $160k where a number is actually disclosed is a veto.
  • Senior IC or not. Junior is a veto. Management is allowed and scored slightly lower, because my next role should be technical.
  • Engineering-adjacent or not. Sales, marketing, design and ops are vetoed at the classifier.

Writing those down as code did something that writing them in a cover letter never did. It made them falsifiable. When karani drops a role I would have applied to, one of those four axes is wrong, and I can see which.

The pipeline drafts the application. I press submit.

karani will never submit anything. That's a non-goal in vision.md, not an unfinished feature, and it's the decision I'd defend hardest.

Three reasons, in the order they matter to me. Some applications have terms about automated submission, and I'd rather not find out the hard way. A model having a bad day at 3am shouldn't be able to fire a broken application at a company I care about. And the thing being automated away, a human reading a posting and deciding it's worth thirty minutes of their evening, is part of what the person on the other side is reading for. On 4 September this pipeline sat dead for eight days without telling me. That's the kind of day I don't want having write access to my name.

What the pipeline hands me is a complete pack in Slack with Approve, Skip and Applied buttons on it. There are 56 such drafts sitting in drafts/ right now, written across the nine days the pipeline was actually healthy. Every one of them waited for me.

The word karani is Swahili for clerk. That's the whole design philosophy in one noun. A clerk reads everything, files everything, tracks everything, and hands you the pen.

What would change my mind

The honest state of this: I've got a working pipeline and I haven't got proof that it works.

vision.md commits to four numbers. Time from "karani ran" to "I know what I'm applying to" under 15 minutes. Qualification precision above 60% once the feedback loop has 30 or more of my own verdicts to learn from. Application-to-screen conversion moving from roughly one in five to better than one in three. LLM cost per applied role under $2.

Three of those four I can't report yet. The funnel instrumentation is the last part of this series for exactly that reason, and if the conversion number doesn't move, the thesis in this post is wrong and I'll write that post too.

Here's the specific thing that would change my mind. If the roles I end up getting interviews for turn out to be ones the pre-filter would have dropped, then the intersection I've encoded isn't the real one, and I've built a very efficient way to miss my own opportunities. That's a measurable outcome, and it's the reason every dropped row keeps its reasons_failed.

What the rest of the series covers

Eleven more posts, each anchored in something already in the repo. 43 commits and 18 ADRs, built in the 16 days between 27 August and 12 September 2026, published as karani 0.4.0 on PyPI. 244 people or robots downloaded it last month, which I mention mostly because it's the only number in this post that has nothing to do with whether it works.

Next up, part 2: why the LLM only ever sees 15 rows, the content hash that makes the repeated 2,200 free, and what a pass actually costs. After that, the application state machine in Postgres columns rather than its own table, the human-in-the-loop design, Slack as a two-way surface for a background agent, the hourly pass as a LangGraph graph, memory that has to earn its keep on mem0 and pgvector, the outage where the watchdog died alongside the thing it was watching, running the expensive tier free on local Ollama models, packaging a personal tool for PyPI, de-slopping machine-written applications with a MinIO artifact store behind them, and finally the funnel that says whether any of this worked.

If you're building something in this shape, the question I'd start with isn't which model to use. It's this one: what fraction of your inbound is worth the expensive path, and what's the cheapest test that finds it?

Building karani

Part 1 of 1