# WebPitch Agent

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

**Category:** AI SYSTEMS / SALES AUTOMATION
**Status:** internal
**Period:** May – July 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**Stack:** Next.js 15, React 19, FastAPI, PostgreSQL, Redis + RQ, Playwright, OpenAI, Google 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.

## The detail

### 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.

### 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.

### 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.

### 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.

### 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.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/webpitch.html)
