All work AI SYSTEMS / SALES AUTOMATION internal

08  /  AI SYSTEMS / SALES AUTOMATION

WebPitch Agent

Cold outreach that leads with a mockup of the website they don't have, and refuses to send by default.

May – July 2026Next.js 15React 19FastAPIPostgreSQLRedis + RQPlaywrightOpenAIGoogle Places API

The challenge

A large number of small businesses run on a Google listing and nothing else. Working that as a pipeline by hand is slow work: search a city, open every listing, check whether a site exists, write something personal, follow up, and stay inside three different email laws. Cold outreach is also the category where dishonest software does the most damage. And I kept running into a smaller version of the same problem in my own build — dashboards that animated toward numbers no job had actually produced.

What I built

I built a Next.js dashboard over a FastAPI backend, RQ workers and a Node + Playwright renderer, wired through swappable adapters: five lead-data providers, three email senders, and an LLM port. A scan pulls listings for a country, region and category, classifies each one as none, social-only, broken, parked or has-site, and only qualified leads continue. For each, the LLM writes a pitch grounded in that business's own facts, with every number in the output checked against them, and the renderer builds a category-specific website preview hosted at /preview/<id>. Nothing sends without passing a fail-closed eligibility gate and a human approving it. I built the system as sixteen parallel workstream branches against a frozen contract spine and merged them with no conflicts.

How it works

AUTOMATED Scan a marketcountry, region, category Classify the listingonly qualified ones go on Pitch and mockupgrounded in the lead's facts FAIL-CLOSED HUMAN The send gatea pure decision, no I/O Refusedthe default answer is no A person approvesnothing sends without it The country picks the rule: CAN-SPAM, CASL, PECR-UK, GDPR-PECR, Australia an unknown country falls to the conservative baseline, not the permissive one Only then does it send the gate, then the person
The mechanism, drawn from the build.

The detail

01

Fail-closed send gate

can_send() is a pure function with no I/O. The service layer resolves every fact first — suppression, rate limit, domain auth, global pause, lawful basis — and the domain layer only decides. A lead's country selects a rule from a table of CAN-SPAM, CASL, PECR-UK, GDPR-PECR and Australian rules; consent-requiring regions refuse unless the operator has affirmed a lawful basis, and an unknown country falls to a conservative baseline rather than the permissive one. I store suppression entries as sha256 hashes, so an opt-out survives the lead record being deleted.

02

Incapable of sending

I ran the parallel build behind BUILD_MODE=true, defended three times over. The adapter factories return the mock branch first and unconditionally. The real senders raise from their own constructors. I lazy-import the network SDKs inside method bodies rather than at module level, so no real host is reachable from an import. Deliberately redundant, so nobody could email a real business by accident while I was building it.

03

A review is hostile input

Sample Google reviews are attacker-controllable text heading straight into an LLM prompt. I wrap each snippet in a labelled untrusted_review block, cap its length, and defang any attempt to forge or close that delimiter by swapping the angle brackets for full-width look-alikes. Then I check the output rather than trust it: every standalone number in the generated subject and body is matched against the lead's actual facts and stripped if it isn't grounded, and I exclude URL and path tokens first so a mockup id's digits aren't read as a claim.

04

The mockup is the wedge

A separate Node and Playwright service renders six category-specific templates — restaurant, salon, clinic, retail, trades, generic — from listing data into genuine HTML and PNG, hosted as a live preview link and screenshotted for the email call to action. I built the PDF proposal path but never switched it on. I gave the renderer one hard invariant: it never fetches a lead's own assets server-side, which keeps SSRF out of the rendering path entirely.

05

Cut along the real seam

An audit found the engine was already vertical-blind — scraping, dedup, suppression, sequencing, deals and analytics don't care what you're selling. The website assumption lived in four places. I extracted the load-bearing one, the need-signal detector, into a NeedSignalDetector protocol with a cheap pure prescreen and a separate expensive-I/O check, and the original website classifier became plugin number one with byte-identical behaviour. SEO-audit, social-media and generic detectors register beside it. I have declared the fact schema, prompt pack and artifact generator as seams but not yet wired them, so pitch copy stays web-design-specific for every vertical.

The outcome

I run the loop end to end on one machine against live providers. My development database holds 16 completed scan jobs and 224 leads carrying real website classifications — 165 with no site, 18 broken, 11 social-only, 4 parked, 16 with a working site, 10 still unknown — plus 10 proposals, 9 of them with rendered mockups. Six email messages were recorded: one logged by the mock sender, five I pushed through the real Resend API with a test redirect pointing every delivery at my own inbox and the nominal recipients left as placeholder addresses. Scrape, classify, generate and render ran for real; I exercised the send path for real but never aimed it at a real business. I never deployed it. An architecture audit catalogued what still blocks multi-tenant SaaS — mock KMS, app-layer-only tenant isolation, RBAC modelled but unenforced, no billing, no cold-email transport — and sequenced it into four phases.