Why I built it

Job searching is one of the highest-stakes outreach operations an average professional will ever run — and it comes with no feedback loop at all.

You send an application into a form and then nothing comes back. Not a rejection you can learn from — nothing. And when a rejection does arrive it is a template, which is the same as nothing. So you never find out which part failed. Was the resume wrong for that req? Were you two years too junior? Had the role already been filled internally before it was posted? Did a keyword filter drop you before a human ever loaded the page? Every one of those has a different fix, and you cannot tell them apart from the outside.

That matters more than it sounds, because every other skill improves the same way: you try something, you observe what happened, you adjust, you go again. Break the observation step and the loop stops being a loop. You are not practising any more, you are just repeating. Months of effort can produce no learning whatsoever, and the person doing it has no way to know that is what's happening.

Which leaves exactly one lever anyone can actually pull: volume.

This is what Easy Apply really is. It is a slot machine button. The cost of a pull is near zero, the payout is rare and unpredictable, and that combination — a reward on a variable schedule for an action that costs nothing — is the most compulsive design pattern we know of. So people spam it. Fifty applications, two hundred, five hundred. It feels productive because the counter goes up, and the counter going up is the only signal the system ever gives back.

But the counter is not the metric. Two hundred applications with no callbacks is not two hundred data points. It is one data point, repeated two hundred times, and you still don't know what it says. The machine took your pull and told you nothing.

The tools built for this market mostly make that worse rather than better, because they optimise the pull rate. Auto-apply bots maximise volume, which is precisely the signal employers filter out first. AI resume tools generate confident prose with nothing underneath it. Enrichment platforms built for sales pipelines will hand you a guessed email address and call it a lead. All of them fabricate, because fabrication is cheap and the cost lands on the candidate — who, of course, never finds out why the outreach went nowhere. The feedback loop is broken there too.

What I built

CareerCRM is an evidence-backed job-search operating system, and it is a bet that the fix is not more pulls — it is fewer, aimed better, each one carrying a reason it should land.

It finds real roles, real people, and real reasons you fit, and it was built specifically to never invent any of them. Instead of firing applications into a void, the unit of work is a named person at a verified employer with a public source URL and a drafted, specific reason to talk to you. That is a target you can evaluate before you spend anything on it, which is the closest thing to feedback the process allows.

So I built on a few rules that were deliberately expensive to keep. Every employer, person, role link and email carries provenance or an explicit unknown state. A referral target needs a name, a current relevant title, an employer match and a public source URL — the email address is optional. The user always sends: drafts get reviewed, Gmail needs explicit approval, LinkedIn stays manual. And the funnel only counts an application when the user says they applied.

The core path came out like this:

Resume Role discovery Tracker Workspace People & referral paths Draft You approve & send

I built it solo — Next.js, TypeScript, Supabase — which mattered more than I expected, because there was nobody in the room to ask me the obvious question.

Lesson 1: discovery was the whole product risk

The two highlighted steps turned out to be where the product lived or died. Everything downstream was workflow, and workflow was tractable. Discovery was not.

Turning a company into named, relevant, hiring-route people with source URLs and usable next actions was the core product risk — not a secondary enrichment step to bolt on at the end. I had originally scoped it as the latter. That was wrong. If that step is unreliable, the rest of the product is an elaborate wrapper around a guess, which is the exact thing I built it to avoid.

So discovery consumed most of the build. 20 August stands out not because it was the day I spent on it, but because it was the day I spent entirely inside a diagnosis that had been wrong since the first hour.

Lesson 2: I diagnosed a fixture and called it the product

The day started with a symptom. Role discovery — the search agent that takes a resume and returns a ranked list of live, verified roles, and the piece I was then calling v1 — came back with zero rows.

Except it hadn't. What returned zero rows was a hand-written candidate-profile fixture I had authored myself, paired with a deliberately hard prompt I had also authored myself. I ran that combination, watched it come back empty, and wrote down "v1 returns 0 rows" as a fact about the product.

It was a fact about the fixture.

Then I did the thing that turns a small mistake into a lost day: I changed three variables at once — a provider pin, the synthetic profile, and the harder prompt, all in the same step. When the result was still zero, I attributed that zero to the engine. There was no way left for it to tell me otherwise. I had removed every path by which the system could contradict me.

The evidence that would have ended this had been on disk the entire time. The application stored its runs. There were 47 real ones sitting in data/careercrm.json, including a complete production run from twelve days earlier that showed exactly what the system did with a real profile.

I opened it in the tenth hour.

Rule 1

Reproduce the real input before diagnosing it. A fixture you wrote tests your assumptions, not your product. And read the telemetry you already have before generating more.

Lesson 3: my "cleanup" would have taken the feature to zero

This was the part that took me longest to write down, because it very nearly shipped.

Somewhere in the fog I noticed a search provider still running that I believed had been retired for v1. That read as an obvious win: dead code quietly burning API credits. A billing leak. I traced it, wrote the fix, and recommended it for deployment.

Then I opened that stored production run.

What it showed: the primary web-search provider had aborted at CareerCRM's own 25-second timeout, returning zero candidates. After that, the provider I had just labelled dead code took over and seeded the structured ATS feeds — and those feeds produced all eight of the jobs the user actually saw.

It was not a leak. It was the thing carrying the feature. Deploying my fix would have taken a working search to zero, and it would have looked like a tidy-up in the diff.

That is what made this failure mode dangerous rather than merely wasteful. A bad diagnosis cost me a day. A confident cleanup built on a bad diagnosis would have cost me the feature — and it would have arrived dressed as an improvement, with a clean rationale and a small footprint.

Rule 2

Before deleting anything that looks redundant, prove what it carries in a real run. "This provider should not be running" is a hypothesis about intent, not evidence about behaviour.

Lesson 4: a written rule with nothing enforcing it is decoration

I had already written the rule that should have contained the damage. It sat in the same file as these notes: no live paid run without first stating the scope, the estimated maximum cost, and the stop condition. A single test run stays under fifty cents unless I explicitly approve more for that exact run.

I blew through it the same day. Three paid runs, and not one went on the product — they all went on bugs in the QA harness I had built to measure the product. I never validated the instrument before I started spending real money through it.

The rule did not survive because nothing enforced it. It was a sentence in a markdown file, which is to say a good intention with no teeth. The version with teeth turned out to be the stored run: when the question is "what does the system already do", the answer is nearly always on disk already, and reading it costs nothing.

Rule 3

Validate the measuring instrument before you spend anything through it. And prefer a stored run to a fresh paid one whenever the question is about behaviour you have already observed.

Lesson 5: what the provider work actually returned

Underneath the lost day, the provider benchmarking did produce real findings. I have stripped the vendor names — these were dated evaluations against my own benchmark, not standing verdicts on anybody's product — but the findings held up.

Web search returned candidate URLs, not truth. It surfaced plausible people, but snippet extraction alone proved brittle: it cheerfully mistook page titles and article fragments for human beings. Users spotted that instantly, and it corroded trust in a way a missing result never did. A wrong name was worse than no name.

Rate limits looked exactly like a broken feature. When a provider throttled and the failure wasn't surfaced, the workflow appeared to simply do nothing. Diagnostics weren't a debugging luxury there; without them an operational condition was indistinguishable from a logic bug — and I went looking for the bug.

Paid enrichment earned its place as a fallback, not a default. One provider returned relevant, correctly-named people in a constrained live probe and was genuinely good. Its credit model also made it far too expensive to call for every company and every person. Quality per call and quality per dollar turned out to be different metrics, and only one of them survived contact with a real user base.

Free tiers lied by omission. One provider's key authenticated perfectly, then refused the only two endpoints the workflow needed. Another returned billing errors on some runs and zero usable people across many benchmark companies. Authentication succeeding told me nothing about whether the plan permitted the thing I was about to build on.

Crawling complemented search; it never replaced it. In-house crawling was good at fetching and parsing pages I already knew about. It was no substitute for discovering off-site profiles, team pages, speaker bios and directory listings in the first place. What worked was: search for candidate URLs, crawl what I was permitted to crawl, extract and rank, then spend money on enrichment selectively.

Lesson 6: discovery is employer intelligence, not generic search

All of the above turned out to be symptoms of one architectural mistake, and naming it was the most valuable thing to come out of the whole exercise.

I had been treating live search results as the source of truth for discovery. They are not. Search returns URLs and snippets; it does not return canonical employers. Building on that foundation produced an endless patch loop — job boards, ATS pages, directories, recruiters, thin snippets, duplicate domains, weak remote and visa signals, publishers that weren't employers at all. Every fix was a special case, and the special cases never stopped arriving, because the underlying entity had never been right.

The core entity for discovery should be a canonical employer object, keyed by a trusted company domain. Search feeds that object as evidence and enrichment — it never becomes the object itself. Candidate generation starts from an employer index, applies evidence gates specific to each bucket of results, then ranks verified employers against the user's resume and target criteria.

That reframing also made the system measurable, which the old one never had been. The bar I set:

Those are properties of the output, not of the pipeline. That was deliberate. It is very easy to build a discovery system that is impressive internally and unreliable externally, and I had already done it once.

Lesson 7: the day was not an anomaly

Doing the retrospective honestly meant admitting the day wasn't bad luck. It was the predictable output of how I had been working.

Too many closed decisions lived only in chat. A decision that exists as a conversation is not closed — it is remembered, and memory is exactly what erodes first. A decision is closed when it is encoded as a test, a decision document, a component or API invariant, or a line on a release checklist. Everything short of that is a plan to remember something later, under worse conditions.

The rework I absorbed traced almost entirely to three things: weak decision retention, a large dirty working tree, and QA that reliably caught compile failures while missing workflow regressions completely. Type-checking proved the code compiled. It said nothing about whether resume → role discovery → people → draft → send still worked end to end, which was the only question that mattered.

Rule 4

A decision is not closed until it is encoded as a test, a decision doc, an invariant, or a checklist item. Small commits, golden-path coverage, deploys from known commits — and freeze adjacent feature work unless it protects the core path.

What I actually took away

Compressed into one line: confidence should decay with distance from observation.

What happened to me that day has a shape. My working model of the system quietly diverged from the real system while my confidence stayed exactly where it started. That is not the same as being wrong. Being wrong is loud — things fail, tests break, something obviously doesn't work. This was silent, and it was productive. It produced clean code, green tests, and a plan that hung together beautifully, all pointed at a layer of the system that had never been the problem.

The tell was never in the output. The output looked fine. The tell was the gap between how sure I felt and when I had last looked at something real — a production run, a stored payload, an actual user session. By hour ten that gap was the whole working day wide, and my confidence had stopped being attached to anything.

Which is the uncomfortable symmetry. I had built a product whose entire promise was that it would not assert things it could not evidence — no guessed hiring managers, no inferred addresses, no company that wasn't actually hiring, every result carrying provenance or an explicit unknown. Then I spent a day asserting things about my own system with no provenance whatsoever, and nearly shipped a fix that would have proved the point for me.

The standard I held the product to was the one I should have been holding myself to. Evidence, or an explicit unknown. There is no third option, and there is certainly not one called "I'm fairly sure."