Skip to content
← All articles

A five-stage outreach engine with zero dependencies

Oct 06, 2026 · Automation · 5 min

·By Dimitri Pisarev

On one machine sits a SQLite file of exactly 628 MiB, 658,427,904 bytes on disk. The file is the warehouse. Inside are 20,180 records from an open data feed, 5,153 organizations, 48,967 address entries across 2,335 cities. The first record landed September 22 at 22:01 UTC, the last October 6 at 07:04 UTC. On top of that file runs an outreach engine with zero runtime dependencies. Its first 48 hours produced 100 deliveries, 50 per day.

None of it is rented. The tooling most firms buy is priced per contact, keeps its scoring closed, and holds prospect data on someone else's disks. This engine runs on its own hardware, and every number below comes from its tables and manifests. The bet: the whole funnel, ingestion through reply triage, belongs on the machine that uses it. The numbers are the check.

Why build instead of rent?#

Renting an outbound pipeline is the default because day one works. The bill arrives later. So do the blind spots: a score you never see deciding whom to approach next, prospect data and send history behind a vendor's API. The same reasoning carried into my German PII redactor, where the market is emptier still: nobody even benchmarks German PII detection.

The second problem is quieter. A five-stage pipeline fails without noise: a feed shifts its shape, a reply lands in the wrong folder. Rented, that failure becomes a support ticket. Owned, it becomes a row you can query.

One file is the whole warehouse#

The database runs in WAL mode. One file holds everything. Two FTS5 indexes tokenize the text in trigrams, per the SQLite FTS5 documentation, so three characters already match. The address index holds 48,967 entries across 2,335 cities, 46,121 of them geocoded, covering 2,176 of those cities.

Note

Every count in this article came from a filesystem copy of the database, opened read-only and immutable through node:sqlite. The original file stayed closed.

Five stages, no broker in between#

The stages are ingestion, matching, enrichment, delivery, triage. Each reads from SQLite and writes back to SQLite. No broker sits between them. No cloud service either.

Ingestion is the only mandatory stage. The feed pulls have run since September 22, logging exactly 222 rows in the search log, one per search term per execution, roughly 54 actual runs by start minute. Two weeks of that cadence filled the warehouse to 20,180 records.

Matching starts with a gate. Every active record passes a taxonomic filter over the category groups of the dataset's catalog, and only survivors move on. The matches table keeps the accounting: 37,059 profile-to-record pairs across three search profiles, 23,828 of them past the gate.

Enrichment turns matched records into reachable ones. Of 173 enriched contact records, 108 carry an email address, and between 103 and 106 carry a postal address with street, code, and city, depending on the field. The range is what the tables support.

Delivery picks from 55 message variants without a model call. An FNV-1a hash over the record identifier, divided by the number of suitable variants, makes the pick. The ledger, the send queue, and the index file agree. 100 rows each. All 100 survive as archived .eml files. The Sent-folder copy is a best-effort IMAP APPEND, present in 97 of the manifests.

Triage is an IMAP reader. It has parsed 30 inbound replies across 23 records, all filed. Its transition log counts 19 acknowledgments, 6 invitations, one pool offer, one decline, one bounce: 28 transitions. Across all 100 deliveries the ledger reads 77 unanswered, 17 acknowledgments, 3 invitations, one pool, one decline, one bounce.

What the ranking actually weighs#

The familiar shape is retrieve, then rerank. This engine splits the work differently. The entire corpus passes the gate first; what survives gets one blended score: 0.5 times BM25, min-max normalized out of FTS5, plus 0.5 times cosine similarity over embeddings. A record without a vector ranks on BM25 alone.

The lexical stage has a useful property. It cannot hide anyone. The BM25 query stops at LIMIT 100, but absence from that list only zeroes the lexical component. A record the lexical query never surfaced keeps its cosine weight and climbs anyway. The evaluation notes call them "Entrants (hybrid only, cosine-driven)."

Only the cosine half leaves the machine: embedding texts, and only while a provider key is set. Nothing else crosses the network. Without a key the BM25 half carries the whole ranking.

Zero dependencies as an operating decision#

The runtime dependency count is zero. package.json lists two devDependencies, TypeScript 7 and @types/node 26. Nothing else. The npm scripts execute src/cli.ts directly under Node 26 (v26.5.0), tsconfig set to noEmit and erasableSyntaxOnly, so no build output can drift. SMTP and IMAP run over node:net, node:tls, node:crypto, ZIP over node:zlib. The DOCX and XLSX writers are hand-rolled.

npm test runs 700 tests offline: 674 pass, 0 fail, 26 skip, the live suite off without LIVE=1. About 23 seconds, no network. Zero dependencies is a stated position on this site. In a send path it is an operating guarantee: an advisory cannot stall a pipeline with nothing to patch.

The part that transfers to a firm#

A company that needs outbound flow is already renting these five stages, per contact per month. The SDR funnel has a fixed shape: target profile, ranked candidates, enriched contacts, personalized first touch, triaged replies. A personal-scale build proves something narrower: the whole funnel runs on one machine, one file, and a score formula short enough to print on one line.

What survives growth is the accounting. Scores explainable from stored components. A ledger with 137 validated status transitions behind every delivery. The full case study keeps architecture and numbers side by side.

The test costs nothing. At week three of any outbound effort, ask what went out, what came back, what changed status. Answers that cost a support ticket mean a dashboard; answers that cost a query mean a pipeline. Start with ingest: point it at a public structured source, let the local file grow, and price stage two once the data is yours.