There are 4,200 licensed, insured plumbing and HVAC contractors in Southern California with five
years or more on the license. 1,832 of them have a working Google listing, a 4.0 rating and at
least ten reviews, and are actually contractors. I built a machine that calls every one of them
between 6 and 9pm, classifies what the phone actually did, and publishes the ones that answered.
No part of it needs me once it starts.
4,200→1,832Licensed → on the dial listPlumbing and HVAC, corp or LLC, workers' comp, five years licensed, on Google with a phone, 4.0+, ten or more reviews.
18–21hDial window, localMonday to Saturday. Never Sunday, never outside it.
30sRing timeoutLong enough for a forwarding chain to an answering service.
0Live humans recordedCalifornia is two-party consent. Only machine greetings are captured.
01
icp-build.py · pool → enrich → stack → export
Build the list
The state license register first — 4,200 plumbing and HVAC licenses. Google only to enrich a company already proven to exist. Six gates and a typed classifier, 1,832 left.
Source
11,107 CSLB licenses across the six Southern California counties — classifications C-36, C-20, C-10 and C-39 — of which 4,200 are plumbing and HVAC.
Spend probe
Each company's own site is fetched once and read for nine signals that cost money — used for ranking, not for the gate. Free analytics tags do not count.
CallRailServiceTitanHousecallProJobberHighLevelGoogleAdsTagMetaPixelPodiumBirdeye
Dedupe
One row per license, then one per Google listing; 3,548 of the 4,200 licenses resolve to a listing.
Output
1,832 companies — 978 HVAC, 854 plumbing — and 743 of them claim 24-hour availability. Every row carries the phone number on the Google listing, which is the one a customer dials.
Typed classification
A typed-decision model reads name, Google category and site title and answers one question — residential service contractor, commercial only, or not a contractor — with a confidence. 90 rows that passed every other gate were garage doors, restoration firms, supply houses and remodel franchises. Below 0.6 confidence the row stays and gets a human look.
Three sources, six gates, one list
02
lead-api · phone-test.ts · Node + TypeScript + Postgres on the VPS
Dial it
One request starts a batch. The batch runs detached; the outcomes arrive on a stream.
Window guard
Monday to Saturday, 18:00–21:00 in America/Los_Angeles — one zone covers all six counties. Outside the window the dial is refused before Twilio is ever called, and the refusal is logged as a skipped_window row.
Batch
20 numbers, one call every 4 seconds, one run at a time.
Dedupe and do-not-call
Deduped at run time on the number actually dialled — 225 of the 1,832 rows share a Google listing with another license. One attempt per number per window. A do-not-call flag is permanent.
Origination
Twilio outbound with Answering Machine Detection, 8s detection timeout, 30s ring timeout. Dialling is double-gated: an explicit server switch plus a bearer secret.
03
Twilio AnsweredBy → TwiML branch
Classify what the phone did
Answering Machine Detection is the front door, so a voicemail or an unanswered call never spends a second of agent time.
The branch
A person answers
The call is parked in a Twilio conference and the ElevenLabs courtesy agent dials into the same conference. It says it is an AI in its first sentence, asks one question, and ends the call. Hard cap 90 seconds.
A machine answers
Up to 18 seconds of the greeting is recorded and transcribed, then the call hangs up. The transcript decides which it is: voicemail_gated if the greeting offers an emergency or on-call path, voicemail_dead if it does not.
Nobody answers
A call that goes unanswered never reaches the TwiML handler at all, so the outcome is written from Twilio's status callback instead, and written once.
Never recorded
No live human is recorded. The courtesy conversation is transcribed for the one answer it asks for and its audio is not retained. Only a machine greeting — broadcast to every caller, not a confidential conversation — is ever captured.
•
Postgres · leads
phone_tests
Append-only. A call's later state arrives as a new row, not an update, so the table is
its own change feed. One row per event: company, city, trade, outcome, the column the number
came from, local time, greeting transcript, call SID.
A 1920×1080 instrument panel. The numbers are the content.
Tally
Dialled · Answered · Voicemail · No answer · Skipped, set large across the top band, each outcome in its own color.
Feed
Server-sent events: the last 50 rows replayed on connect, new rows polled every 1 second, a keepalive every 10 so the proxy cannot reap the stream.
Audio
The browser joins the same Twilio conference muted through the Voice SDK, so the call plays out of the tab and OBS or a TikTok Live picks it straight off desktop audio.
Frame
The bottom-right 400×300 is kept empty for the webcam plate. Nothing the page draws is allowed to enter it.
05
/responsive · /responsive/{city}-{trade}
Publish it
The most responsive plumbers after hours, by city. Generated from the same rows the dialer wrote.
Who gets named
Only a company whose outcome is answered gets named, with the time it picked up. A company that missed the call is named nowhere, ever.
One page per cell
A city and trade needs at least 3 companies that answered before it gets a page. Indexable, in the sitemap, built for consumer search.
Posts
Day 0 is the setup — the 1,832 numbers and the promise of that night's calls. Day 1 is the outcomes, plus the screen recording. Weekly after that, templated from the table.
Attribution
The short paths /tt /ig /x all resolve to the directory, so a typed URL says which platform it came from.
06
/list → Supabase → Resend · /try
Sell it
Two things come off one engine: the dataset, and the voice agent the whole demonstration is about.
The city list
$999 per trade per city: every licensed, insured company in that trade, with verified phone, reviews, the software they run, and whether they answer after hours. Not a sample — every contractor in the trade a customer could actually find and call.
The waitlist
Four fields — city, trade, what do you do, email — validated server-side, written to Supabase, confirmed by a one-line Resend email. Honeypot on the form, no marketing in the reply.
The product
The voice agent at /try — a company that hears its own line answered is hearing the same stack that just called it.
Why it holds
After-hours response behavior is a column no contact-data vendor has, because it only exists once somebody actually places the call.