crisgth Book a build call
Leadd — Build Log
Build log · 2026-06-24 → 2026-07-16 · solo
LEADD

I stopped renting lead‑gen tools and built the whole machine.

Three weeks. 393 commits. A pipeline that finds businesses, digs out their real email, verifies it before it dares send anything, scores it with an LLM, routes it to a sequencer — and then changes its own mind about where to look next week.

leadd · ops console phosphor
00Stage · thesis

The one rule that shaped every file

Everything I build now runs into the same wall: the tool is 80% of what I need, the last 20% is where the money is, and the vendor will never build it because it's specific to me. So I pay $400 a month to be almost right.

Leadd started as a rule written at the top of the repo, before a single component existed:

Don't rebuild commodity capability. Call the APIs. Spend every line you write on judgment the vendors can't sell you.

That rule does a lot of work. It means no homemade scraper — Outscraper already talks to Google Maps better than I will. No contact database — Prospeo, Findymail and Dropcontact already have one. No email validator — ZeroBounce is $0.004 a check and better than anything I'd write.

What it leaves me is the part nobody sells: deciding what to look for, knowing when to stop, and learning from what came back. That turned out to be roughly 29,000 lines of it.

Commits
393
in 22 days
Source files
681
TypeScript, strict
Test files
242
~13k lines
Flows
24
durable, replayable
Entities
39
one live schema
Screens
36
26 desktop · 10 mobile

The stack, and why

LayerPickThe reason it won
AppNext.js 16 · App RouterOne repo for the console, the API and the cron surface. Turbopack keeps a 681-file project honest.
OrchestrationInngest v4The whole thesis. Every stage is a step.run — crash mid-run and it resumes at the exact step, not the top.
DataInstantDBLive queries straight to React. The console updates while a run is executing, with no socket code.
IntelligenceOpenRouterOne adapter, every model. Swap the scoring model from a dropdown; no vendor SDK ever gets imported.
ValidationZod v4Nothing from an external API reaches the database unparsed. Not once.
MediaBackblaze B2Learned the hard way. More on that below.
/ command center mock data
leads
4,182
contactable
61%
dispatched
1,904
replies
87
cost / lead
$0.041
spend today
$2.31
Funnel · last 7d
discovered2,140
persisted · deduped1,663
enriched · email found1,014
verified · dispatchable742
scored ≥ 45508
routed to sequencer508
Deliverability gate
valid612pass
unknown130pass
catch_all188held
risky44held
invalid40blocked
272 addresses withheld from dispatch this week.
sender reputation protected · bounce rate 0.4%
Work now · hot leads
Casa Verde Dentalhot91Alajuela
Studio Mar Pilateshot88Escazú
Nómada Coworkinghot84Tamarindo
Clínica Rivas ORLwarm69Heredia
Boutique Solazwarm61Santa Ana
Terraza Lunáticawarm57Jacó
sorted by score × recency · 6 awaiting your call
System health
outscraperok · 214ms
zerobounceok · 96ms
smartleadok · 302ms
openrouter402 · degraded
inngest24 fns registered
OpenRouter out of credit → leads stay unscored, pipeline still enriches + verifies. No run aborted.
The command center. Every tile is a widget you can reorder or switch off — the layout is a per-user preference, not a fixed dashboard. Note the amber row in system health: the LLM is out of credit and the pipeline is still running. That behaviour is deliberate and it's in the next section.
01Stage · discover

The hardest problem was not scraping. It was repeating yourself.

Any tool can pull 200 dentists in Madrid. Pull them again next Tuesday and you get the same 200 dentists. Every lead tool I've used quietly hands you the same list forever and lets you feel productive about it.

The provider's own answer is a skip parameter — ask for results 200–400 instead of 0–200. I built on that first. Measured live, it returned 67–80% duplicates. The pagination doesn't hold across calls the way you'd hope.

So I threw it out and made freshness a property of the system instead of the provider. Every search is reduced to a fingerprint of (vertical, location, region, language). That fingerprint goes into a global 90-day lock — global meaning across every plan, not per-plan. If any plan searched "dentists / Alajuela" six weeks ago, no plan searches it again for another six.

The plan then walks a ladder of markets rather than paging deeper into one. Duplicate rate measured live after the change: zero.

ApproachDuplicate rateWhy
Provider skip pagination67–80%Offsets don't hold across calls; the same rows come back.
Ladder-first rotation + 90-day global lock0%Never asks the same question twice inside the freshness window.

When a plan exhausts its entire fresh space, it doesn't fail and it doesn't spin. It sets a 30-day cooldown, closes the run at zero, and reschedules itself past the cooldown. A criterion is never left perpetually "due" — that was a bug I shipped once and it cost me a week of phantom runs.

02Stage · persist

Every step is idempotent, or the whole thing is a toy

This is the constraint that made the codebase what it is. It's written into the project rules in one line: every step idempotent; dedupe by placeId; re-running a flow never double-writes.

Sounds obvious. It isn't, and here's the shape of the bug that teaches you. Inngest replays a function from the top on every step boundary — code outside a step.run re-executes each time. So this:

BrokenFixed
const runId = id() const runId = await step.run("init-run", () => id())
A bare id() generates a different id on every replay. Step 3 writes to a run that step 8 can no longer find. The run looks fine, finishes green, and silently orphans its own record. Memoising it inside a step pins it for the life of the execution.

Every stage after that is written to survive being run twice: enrichment returns early if the lead already has an email; scoring returns early if it already has a score unless you explicitly force a re-score; the discovery upsert dedupes on placeId before it writes anything.

why it matters A run that dies at 04:12 resumes at 04:12. Not from the top, not from a manual cleanup script, not from you re-reading logs on a Sunday. It costs no extra provider credits to recover, because the completed steps are never re-executed.
/runs durable execution mock data
harvester · "pilates studios in Escazú" running run_8f2ac1 · attempt 2 of 3 · resumed after 502
#StepStateDurationDetail
01init-runmemoized2msrunId pinned for all replays
02resolve-modelmemoized41msopenrouter · settings default
03startdone88msruns row → status running
04discover:syncdone6.2soutscraper · 118 businesses
▲ 502 from providerretrybackoff 4s · resumed at step 05
05persistdone1.1s94 new · 24 deduped on placeId
06enrich:ChIJd8…d4Qdone820msprospeo → hit
07verify:ChIJd8…d4Qdone96mszerobounce · valid
08score:ChIJd8…d4Qdone1.9sllm · 84 · tier hot
09images:ChIJd8…d4Qdone2.4s3 photos → B2 (re-hosted)
93 more lead chainsexecutingconcurrency limit 3
98route → smartleadqueueddispatchable only
99finishqueuedstats + telegram notify
▸ a 502 at step 04 cost 4 seconds, not 118 businesses steps 01–09 will not re-execute on the next replay
What durable execution actually looks like. The provider threw a 502 mid-run. The engine backed off, resumed at the exact next step, and kept every credit already spent. The red row is the whole argument for building this on a step function instead of a cron job.
03Stage · enrich

Three providers, one waterfall, first hit wins

A business listing gives you a name, a website and a phone. It rarely gives you an address a human reads. So enrichment is a waterfall: Prospeo → Findymail → Dropcontact. First one that returns an email wins, and the lead records which provider found it.

That last part is the whole point. Storing enrichmentSource means I can ask, months later, which provider actually earns its invoice on my verticals in my countries. That's not a question any of the three will answer for you.

Every adapter in the repo — all twenty-plus of them — follows the same contract, because they were painful in the same ways:

04Stage · verify

The gate that says no

This is the single most valuable line in the codebase and it's a Set with two strings in it.

Only valid and unknown are ever dispatchable. Never invalid. Never risky. And never, ever catch_all.

Catch-all is the trap. A catch-all domain accepts every address, so it looks like a pass. It's actually unverifiable — you're guessing, and the mailbox may not exist. Send enough of them and your sender reputation degrades quietly over weeks. By the time you notice deliverability dropping, the damage is already priced into your domain.

In the mock week above that gate withheld 272 addresses. Those are 272 sends I'd otherwise have made, and the reason the bounce rate stays under half a percent. The most profitable thing this system does is refuse to send email.

war story · the cost leak I was flying blind on spend for two weeks. Cost tracking was wired on one endpoint but not the other two. Tracked spend read $0.64. Actual spend was $9.37 — an order of magnitude out, with a budget cap sitting on top of it that could never fire because it was watching a number that wasn't real. Compounding it, a non-idempotent retry path was re-pulling results I'd already paid for. Every paid endpoint now reports itemised cost back into the same ledger, and the cap reads that ledger. The lesson wasn't "add tracking" — it was that a budget cap over partial instrumentation is worse than no cap at all, because it manufactures confidence.
05Stage · score

Rank, don't park

Scoring is the one place an LLM belongs in this pipeline. The model gets the whole picture — name, bio, category, rating, review count, socials, website state, whether the email verified, how many times we've seen this business before — and returns four things: a score, a tier, a whyNow, and an angle.

The last two matter more than the number. whyNow is the reason this business is worth contacting this week. angle is the opening line's actual argument. A score sorts a list; those two write the email.

All model calls go through one file. Never a vendor SDK, always OpenRouter, and the model is picked from a dropdown in the UI. Changing the scoring model is a settings change, not a deployment.

Failure is a design decision

Here's the behaviour I'm proudest of. When the LLM is unavailable — and it has been, mid-run, out of credit, with a 402 — scoring returns null and the pipeline keeps going.

The temptation is to throw. It feels correct: the step failed, fail the run. It's wrong. Enrichment and verification run on different, already-paid-for providers, and they're downstream. Aborting on an LLM outage throws away the paid contact data to protect a number I can backfill for free tomorrow. So the gate ranks, it doesn't park. An unscored lead flows on, and a backfill scores it later.

/leads tactical ops-grid mock data
tier: hot dispatchable CR · ES score ≥ 45 508 rows · 12 selected ⌘K
12 selected ▸ dispatch▸ re-score▸ tag▸ enrich▸ export esc to clear
◤ tier · hot— 46 leads · avg 82
NameScoreTierEmailChannelsCityNext actionRating
Casa Verde Dental
91
hot valid ✉ ☏ ⌘ ⓘAlajuela dispatch · seq 24.8
Studio Mar Pilates
88
hot valid ✉ ⌘ ⓘEscazú dispatch · seq 14.9
Nómada Coworking
84
hot catch_all ☏ ⌘Tamarindo held · unverifiable4.7
Clínica Rivas ORL
69
warm valid ✉ ☏ ⓘHeredia draft reply4.4
Boutique Solaz
61
warm unknown ✉ ⌘Santa Ana dispatch · seq 14.2
Gimnasio Atlas
unscored valid ✉ ☏Cartago backfill queued4.1
▸ columns are user-configurable + resizable▸ grouping: tier▸ virtualised to 40k rows
The ops-grid. Row 3 is the verify gate doing its job in public — an 84-scoring lead held back because its domain is catch-all. Row 6 is graceful degradation: unscored because the LLM was down, still verified, still queued for backfill. Neither is an error state. Both are the system being correct.
06Stage · route

Handing off to the sequencer

Qualified leads go to Smartlead, grouped into a campaign named after the query that found them. That's it — I'm not rebuilding an email sender, and warming domains is somebody else's full-time job.

What I did build is everything around the handoff: the offer matrix, the segment taxonomy, and a message studio that composes DMs and emails from templates against the lead's angle and whyNow. The sequencer sends. The engine decides what's worth sending and what it should say.

07Stage · learn

The part that makes it a machine instead of a script

Stages 01–06 are a pipeline. Plenty of people have built one. Stage 07 is the reason this is a different category of thing.

Every plan carries a rolling quality average — an EWMA over its runs, α = 0.5, so it's responsive to the most recent result rather than anchored to ancient history. Before each run the plan reads that number against a bar of 55 and makes one of two decisions:

Rolling qualityDecisionWhat actually happens
≥ 55KEEPThe vertical is working. Hold it, take its top-ranked city that isn't inside the 90-day global lock.
< 55PIVOTThe vertical is weak. Advance to the next vertical entirely, and take a fresh market there.
all mined outCOOLDOWNWhole fresh space exhausted. 30-day sleep, run closes at zero, schedule advances.

The plan corrects its own aim. Nobody logs in on Monday to notice that dentists in Costa Rica have stopped converting — the rolling average notices, and next Tuesday it's running physiotherapists instead.

And because the forecast is computed by the same function the runtime uses, the console can show you the next eight runs before they happen. Not an estimate. The actual picks.

/criteria/plan_4c9 evolution brain mock data
Wellness · LATAM ladder scheduled · weekly system: maps-business · next run in 2d 04h
rolling quality · EWMA α=0.5
62
0bar 55100
decision this cycle
KEEP
quality 62 ≥ bar 55 → hold vertical
advance city only
freshness lock
90d
global across all plans
217 fingerprints locked · 0% dup
Forecast · next 8 runs — computed by the same picker the runtime uses
DateVerticalMarketDecisionWhy
Aug 11pilates studiosEscazú · CRkeepquality above bar
Aug 18pilates studiosSanta Ana · CRkeepnext untried city
Aug 25pilates studiosHeredia · CRkeepnext untried city
Sep 01pilates studiosValencia · ESkeepCR cities mined out → region roll
Sep 08physiotherapyMálaga · ESpivotprojected quality < 55
Sep 15physiotherapySevilla · ESpivotcontinue new vertical
Sep 22physiotherapyZaragoza · ESpivotcontinue new vertical
Sep 29dental clinicsAlajuela · CRpivot90d lock expires · re-eligible
▸ every row is a fingerprint (vertical · location · region · language) checked against the global 90-day set — not this plan's history, every plan's.
A plan changing its own mind, eight weeks out. Rows 1–4 hold the working vertical and walk the market ladder. Row 5 is the pivot: projected quality drops under the bar, so the plan abandons pilates and moves to physiotherapy on its own. Row 8 is the 90-day lock expiring and releasing a market back into play.
08Layer · oracle

Then I gave it a second brain that reads the world

Stage 07 optimises inside a space I defined. It gets better at my verticals in my markets. It will never tell me the vertical is dying, or that a category I've never touched is about to spike.

So the Oracle is a separate spine: sensors → signals → opportunities. Sensors run on a schedule and normalise wildly different sources into one signal shape — Google Trends, GDELT news, Polymarket prediction markets, Reddit, Wikipedia pageviews, CoinGecko, X. Each source carries a trust weight. Signals get a cheap deterministic salience score first — momentum log-scaled, weighted by kind, with a geo-relevance bonus — and only the survivors are worth an LLM call.

Opportunities land in four tiers, at two altitudes: tactical, which writes back into the lead engine, and strategic, which is the "what business should exist" question.

TierQuestion it answersWired action
businessSomething is moving inside a vertical I already work.boost_market
nicheAn adjacent category is heating up.add_seed · new_niche
disruptionSomething structural is about to change this market.outreach_angle
eventA dated thing is coming that creates a window.prepare · watch

Two things keep it from being an astrology generator. First, every opportunity carries its receipts — the exact signal and topic ids it was built from, plus one deterministic line of what the data actually shows. Nothing gets asserted without a citation you can click.

Second, it learns my taste. I mark calls gold, good, wrong or irrelevant, and those verdicts tune weights along four dimensions — signal kind, tier, altitude, and source. Mark enough Polymarket-derived disruption calls as irrelevant and the Oracle stops leading with them. It's a preference model with a human in the loop, not a feed.

/radar oracle war room mock data
Opportunity deck — 14 open · sorted by tuned score
87 Recovery studios spiking across ES metros nichetactical
"cryotherapy" + "contrast therapy" breakout in 4 ES metros; 3 of 4 have no incumbent chain in Maps. Adjacent to a vertical already at quality 62.
▸ google-trends ×3 ▸ reddit ×2 ▸ wikipedia ▸ add_seed▸ boost_market
74 Card-fee rules land Q4 — SMB pricing pages break disruptionstrategic
Prediction market at 0.81 on Q4 enforcement; GDELT coverage up 340% in 21d. Every SMB with published card pricing needs a rewrite in the same quarter.
▸ polymarket 0.81 ▸ gdelt ×11 ▸ outreach_angle
58 Dental interest decaying in CR — 3rd week down business
−18% interest over 21d, consistent across 3 geos. Plan wellness·LATAM already trending toward its pivot bar.
▸ google-trends ×3 ▸ decline
52 Dental congress lands in San José — 3-week window event
Dated Mar 4–6. 400+ CR clinics travel to one city. Pre-seed the market 5 weeks out; the 90-day lock on dental·CR expires Feb 28.
▸ gdelt ×2 ▸ operator ▸ prepare▸ watch
Signal feed
cryotherapybreakout94
contrast therapy+840%88
card fee rulingprediction81
padel courts ESrising67
sauna clubsgeo_rise54
dental CR−18%41
Operator loop · your verdict
★ gold good wrong irrelevant
tuned weights →
kind breakout 1.24× · prediction 0.71×
source trends 1.18× · x 0.64×
tier niche 1.31×
Sensor rack
google-trends6h1.0
gdelt1h0.8
polymarket12h0.9
reddit6h0.6
wikipedia24h0.7
coingecko1h0.5
xrate-limited0.6
cadence · trust weight
▸ every card cites its signal ids · actions write back into the lead engine — "add_seed" creates a criterion, "boost_market" raises a plan's target
The Oracle. Left: opportunities with their evidence attached. Right: the signal feed, and the weights my own verdicts have tuned — this operator has been marking prediction-market calls down (0.71×) and niche calls up (1.31×), so the deck re-sorts itself around that. Card 3 is the interesting one: the Oracle spotted a vertical decaying before the plan's rolling average crossed its pivot bar.
09Layer · console

I refused to build another admin panel

A machine that runs itself still needs a cockpit, and I had a strong opinion about what it should feel like: the Nostromo, not a SaaS dashboard. Phosphor green on black, monospace numerals, scanlines, hairline grids, glowing readouts. Three themes ship — Phosphor CRT (default), Industrial, and Prometheus — all driven by CSS custom properties, so the entire console re-skins from one token block.

This is not decoration. Density is the feature. A dense readout means one glance answers "is anything wrong", and the aesthetic gave me permission to put six numbers where a normal dashboard puts one.

The console covers 26 desktop routes and a separate 10-route mobile build, plus a command palette, a chat terminal wired to the same model layer, push notifications, and a Telegram bot for when I'm not at a desk.

/ · widgetlead trajectorymock
DRAG TO SCRUB · 1–180D
hot ≥70warm ≥45cold <45 qualitycountryniche
/outreachpaid adsmock
spend
$2,957
cpl
$4.12
cpm
$8.44
roas
0.0×
campaigns
62
active
0
DAILY SPEND · 30D
CampaignStateSpendAds
RT · site visitors 30dpaused$61214
LAL 1% · purchaserspaused$48811
Broad · CR wellnesspaused$3779
59 morepaused$1,480168
Left: leads plotted as orbits — distance is recency, colour is quality band, drag to scrub a 180-day window. Right: the ads surface. That 0.0× ROAS against $2,957 of spend is the shape of the real data the adapter returned on day one, and it's exactly the sort of thing a dashboard should refuse to make look pretty.
10The whole thing · one diagram

How it fits together

Two loops. The inner loop is the pipeline: discover, persist, enrich, verify, score, route. The outer loop is what makes it a machine — outcomes feed the rolling quality average, which changes what the next run looks for. The Oracle sits outside both, watching the world and writing new seeds into the plan set.

architecturetwo loopsschematic
ORACLE · WORLD SENSORS SENSORS trends · gdelt · reddit SIGNALS salience 0–100 OPPORTUNITIES 4 tiers · with receipts add_seed PLAN SET CRITERIA · PLANS rotation · 90d global lock keep / pivot / cooldown PIPELINE 01 DISCOVER outscraper maps apify · x · ig 02 PERSIST upsert by placeId idempotent 03 ENRICH prospeo → findymail → dropcontact 04 VERIFY zerobounce gate valid | unknown only 05 SCORE llm · openrouter rank, don't park 06 ROUTE smartlead dispatchable only HELD · NOT SENT catch_all · risky · invalid FEEDBACK LOOP EVOLUTION · EWMA α=0.5 quality ≥ 55 keep · < 55 pivot OUTCOMES replies · deals · quality RE-AIMS THE NEXT RUN
Green is the pipeline. Amber is where the system says no or changes its mind. The line running from OUTCOMES back up into the plan set is the only part of this diagram that a normal lead tool doesn't have.
11Results · what changed

Outcomes

These are engineering outcomes, measured against real providers and a real database — not projections, and not customer metrics. Where a number describes my own operation I've said so.

BeforeAfterHow it was verified
67–80% duplicate leads per re-run using provider pagination 0% duplicates Live runs against Outscraper after switching to ladder-first rotation with a global 90-day fingerprint lock.
Spend visible on 1 of 3 paid endpoints; tracked $0.64 vs actual $9.37 Itemised cost on every endpoint Provider balance API reconciled against the internal ledger; the budget cap now reads a number that exists.
A failed run restarted from the top, re-paying for completed work Resumes at the failed step Inngest step boundaries + idempotent writes; verified by killing runs mid-flight and watching them resume.
Unverifiable addresses reaching the sequencer catch_all / risky / invalid never dispatch A hard gate at the routing boundary, with unit tests asserting each status.
Lead photos silently going blank after a few days Re-hosted on first sight Root cause was expiring Google Maps URLs, not a schema bug. Confirmed in production logs, then backfilled live with zero upload failures.
An LLM outage aborting a run and wasting paid contact data Degrades, doesn't abort Scoring returns null on provider failure; enrich and verify complete; a backfill scores later.
the one that hurt A passing test suite is not acceptance. One phase shipped green — every mock satisfied — and a live audit against real providers found nineteen functional gaps behind it. All nineteen are fixed now, across three follow-up branches, each one verified against the real database and the real APIs rather than against a mock. The rule I work by since: live-verify is the acceptance criterion. A green suite is permission to start testing, not evidence of done.
12Value · why it's worth it

Benefits

13The argument · disruption

Why the lead-tool category is in trouble

Not because of this project. Because of what this project cost to build.

The moat around most B2B SaaS was never the features. It was that assembling those features used to take a team a year.

Look at what Leadd actually is: a thin orchestration layer over commodity APIs, plus my specific judgment about who's worth contacting and when to stop looking. The commodity half is rented and interchangeable. The judgment half is 29,000 lines that took three weeks — because the tedious parts, the adapter boilerplate, the schema wiring, the test scaffolding, no longer take the time they used to.

Three shifts stack here, and each one on its own is survivable:

ShiftWhat it removes
Every capability is an APIYou no longer need to own a contact database, a scraper, or a validator to have one. The vendor's data advantage is available to their competitor for four-tenths of a cent.
Durable execution is a primitiveReliable long-running orchestration used to be the hard engineering that justified the seat price. It's now a library and a decorator.
Assembly cost collapsedThe integration work that made "just build it" unrealistic for a one-person operation is now days, not quarters.

What survives that is not features. It's the two things a vendor structurally cannot sell you:

So the disruption isn't "AI writes your outreach." Everyone shipped that. It's that the vertical SaaS layer between the APIs and the operator is getting squeezed to nothing — because the operator can now build the layer themselves in the time it takes to evaluate three vendors, and the version they build knows things the vendor never will.

The obvious counterargument is maintenance: I now own twenty API integrations that will break. That's real, and it's the honest cost. But it's also what the $400/month was buying, and the difference is that when a provider changes its response shape I fix it in an afternoon rather than filing a ticket and waiting for a quarter.

14Status · what's next

Where it stands

The engine is functional, hardened and feature-complete. The console covers 36 routes. The Oracle spine is live with seven sensors and a working operator-feedback loop. Paid ads reporting reads real account data end to end.

Open work, in the order it matters:

Duration
22d
Jun 24 → Jul 16
Commits
393
~18 / day
Ship lines
29k
TypeScript strict
Test lines
13k
242 files
Adapters
20+
one contract
Themes
3
CRT · Industrial · Prometheus