AI Business Marketplace

We build the businesses. You buy the code and run them up*

Plumber Agency

Plumber Agency

Inside the box

This is the real product tree — open files are free to read; the rest unlocks when you become CEO.

The file tree

Every file below ships in the box. 📄 marked files are previewed in full further down this page. 🔒 marked files are the paid product — the operator brain, the prompt pack, the sales scripts, the fulfillment templates.

plumber-agency/
├── START_HERE.md 📄 preview available
├── CLAUDE.md 🔒 Unlocks with purchase
├── config/
└── business.yaml 🔒 Unlocks with purchase
├── scraper/
├── hand-list-template.csv 🔒 Unlocks with purchase
├── leads.py 🔒 Unlocks with purchase
├── README.md 📄 preview available
└── requirements.txt 🔒 Unlocks with purchase
├── agency-site/
├── fill.py 🔒 Unlocks with purchase
├── index.html 🔒 Unlocks with purchase
├── README.md 🔒 Unlocks with purchase
├── script.js 🔒 Unlocks with purchase
├── style.css 🔒 Unlocks with purchase
├── EXAMPLE/
│   │   ├── classic/
│   │   │   ├── index.html 🔒 Unlocks with purchase
│   │   │   ├── script.js 🔒 Unlocks with purchase
│   │   │   └── style.css 🔒 Unlocks with purchase
│   │   └── ironclad/
│   │       ├── index.html 🔒 Unlocks with purchase
│   │       ├── script.js 🔒 Unlocks with purchase
│   │       └── style.css 🔒 Unlocks with purchase
└── variant-b/
├── fill.py 🔒 Unlocks with purchase
├── index.html 🔒 Unlocks with purchase
├── script.js 🔒 Unlocks with purchase
└── style.css 🔒 Unlocks with purchase
├── fulfillment/
├── care-plan-sop.md 🔒 Unlocks with purchase
├── check-build.py 🔒 Unlocks with purchase
├── CLAIMS.md 🔒 Unlocks with purchase
├── delivery-checklist.md 🔒 Unlocks with purchase
├── publish-a-demo.md 🔒 Unlocks with purchase
├── missed-call-textback/
│   │   ├── one-pager.md 🔒 Unlocks with purchase
│   │   ├── README.md 🔒 Unlocks with purchase
│   │   ├── setup-sop.md 🔒 Unlocks with purchase
│   │   └── text-templates.md 🔒 Unlocks with purchase
├── review-engine/
│   │   ├── ask-flow.md 🔒 Unlocks with purchase
│   │   ├── bad-reviews.md 🔒 Unlocks with purchase
│   │   ├── make_qr.py 🔒 Unlocks with purchase
│   │   ├── monthly-cadence.md 🔒 Unlocks with purchase
│   │   ├── one-pager.md 🔒 Unlocks with purchase
│   │   ├── qr-card.html 🔒 Unlocks with purchase
│   │   ├── README.md 🔒 Unlocks with purchase
│   │   └── requirements.txt 🔒 Unlocks with purchase
├── website-template/
│   │   ├── index.html 🔒 Unlocks with purchase
│   │   ├── README.md 🔒 Unlocks with purchase
│   │   ├── script.js 🔒 Unlocks with purchase
│   │   ├── styles.css 🔒 Unlocks with purchase
│   │   └── EXAMPLE/
│   │       ├── index.html 🔒 Unlocks with purchase
│   │       ├── README.md 🔒 Unlocks with purchase
│   │       ├── script.js 🔒 Unlocks with purchase
│   │       └── styles.css 🔒 Unlocks with purchase
└── website-template-b/
├── index.html 🔒 Unlocks with purchase
├── README.md 🔒 Unlocks with purchase
├── script.js 🔒 Unlocks with purchase
├── styles.css 🔒 Unlocks with purchase
└── EXAMPLE/
├── index.html 🔒 Unlocks with purchase
├── README.md 🔒 Unlocks with purchase
├── script.js 🔒 Unlocks with purchase
└── styles.css 🔒 Unlocks with purchase
├── sales/
├── call-script.md 🔒 Unlocks with purchase
├── objections.md 🔒 Unlocks with purchase
└── outreach.md 🔒 Unlocks with purchase
├── prompts/
├── 01-prep-calls.md 🔒 Unlocks with purchase
├── 02-research-lead.md 🔒 Unlocks with purchase
├── 03-fulfill.md 🔒 Unlocks with purchase
├── 04-invoice.md 🔒 Unlocks with purchase
├── 05-weekly-review.md 🔒 Unlocks with purchase
└── 06-upsell.md 🔒 Unlocks with purchase
├── playbook/
├── growth.md 🔒 Unlocks with purchase
├── month-1.md 🔒 Unlocks with purchase
└── week-1.md 📄 preview available
├── docs/
├── connect-claude.md 📄 preview available
├── FIELD_TEST.md 📄 preview available
├── rules-of-the-road.md 🔒 Unlocks with purchase
├── see-it-first.md 🔒 Unlocks with purchase
├── setting-up-shop.md 🔒 Unlocks with purchase
├── what-you-will-see.md 🔒 Unlocks with purchase
└── your-phone-setup.md 🔒 Unlocks with purchase
├── office/
├── client-tracker.csv 🔒 Unlocks with purchase
├── data-handling-addendum.html 🔒 Unlocks with purchase
├── data-handling-addendum.md 🔒 Unlocks with purchase
├── do-not-call.md 🔒 Unlocks with purchase
├── EXAMPLE-call-list.md 🔒 Unlocks with purchase
├── EXAMPLE-client-tracker.csv 🔒 Unlocks with purchase
├── EXAMPLE-invoice.html 🔒 Unlocks with purchase
├── EXAMPLE-proposal.html 🔒 Unlocks with purchase
├── EXAMPLE-service-agreement.html 🔒 Unlocks with purchase
├── intake-form.html 🔒 Unlocks with purchase
├── invoice-template.md 🔒 Unlocks with purchase
├── invoice.html 🔒 Unlocks with purchase
├── onboarding-emails.md 🔒 Unlocks with purchase
├── proposal-onepager.html 🔒 Unlocks with purchase
├── quoting-guide.md 🔒 Unlocks with purchase
├── README.md 🔒 Unlocks with purchase
├── recurring-services-agreement.html 🔒 Unlocks with purchase
├── recurring-services-agreement.md 🔒 Unlocks with purchase
├── service-agreement.html 🔒 Unlocks with purchase
├── service-agreement.md 🔒 Unlocks with purchase
└── tracker-guide.md 🔒 Unlocks with purchase
├── LICENSE.md 🔒 Unlocks with purchase
├── README.md 🔒 Unlocks with purchase
└── verify-this-kit.py 🔒 Unlocks with purchase

The open files

Read them in full — this is the same text a CEO gets. Nothing here is a mockup.

📄 START_HERE.md

Open file

START HERE — Your Plumber Web Agency

You just bought a starter system — not a business with customers, and not an idea either. It's the whole machine: a lead scraper, a call script, a website product to sell, a client agreement, the prompts and playbooks to run it, and an operator brain your Claude loads automatically. There's no revenue in the box. You bring the calls; everything else is here.

The business: local plumbers make real money and have websites from 2009 — or none at all. You sell them a professional, mobile-first website, built with AI and live the same week. Builds like this commonly go for $500–$1,500 one-time (market rates, not promises — your calls and your market set your results). After a happy delivery you offer the upsells: missed-call textback, a Google review engine, and a $99–$299/mo care plan. That recurring part is the real business.

What the first two days actually look like — the honest version, because the whole kit is built around it:

Day 1 is setup and building your call list. Day 2 is calling it.

Nobody scrapes a city at 9am and dials 25 people at 10. Free map data is generous in some towns and thin in others, so day one ends with a list, not with sales calls. Expect 2–3 hours on day one, most of it one-time setup you never repeat.

You're the CEO. Your Claude is the operator. Here's day one:


0. Check the box is clean (10 seconds, do it first)

In a terminal, from this folder:

python3 verify-this-kit.py          # Windows: py -3 verify-this-kit.py

It should print "All checks passed." That confirms your copy contains no leftover files from anybody else's run — no other city's leads waiting to contaminate your first scrape, no half-built client site, and a blank config/business.yaml waiting for your details rather than a stranger's.

If it lists problems, it names the exact files and they are all safe to delete. (If you don't have a terminal open yet, skip to step 1, install Python, then come back — it takes ten seconds.)

0b. See what you're selling (~20 min, no setup)

Open docs/see-it-first.md and follow it. Ten finished, filled-in files — including the client website built twice (two designs, same made-up plumber) and your own agency storefront built in two looks. No terminal, no Claude, no accounts. Just double-click and look.

It is much easier to sell something you have seen. Everything in there is clearly labelled as a demo: the businesses are invented, and this kit has never signed a client — every example page says so on itself.

1. Connect your Claude and install Python (~25 min, one time)

Follow docs/connect-claude.md — it assumes you've never touched a terminal and walks you through every step, including Step 2.5, installing Python. Don't skip 2.5. The lead scraper is a Python program, Python is a free five-minute download, and it is the single most common place people get stuck. Everything after it is typing and talking.

You're done when two things are true:

  • You ask Claude "what business is this?" and it answers like it runs the place.
  • python3 leads.py --help (Windows: py -3 leads.py --help) prints a page of options instead of an error.

Windows CEOs: everywhere this kit says python3, you type py -3. That's the whole difference. Your Claude knows it — just tell it you're on Windows once.

2. Make it yours — name it, then change the words (~5 min now, more later)

Open config/business.yaml in any text editor (or just tell Claude your details and let it fill the file). Your name, your agency name, your city and state, phone, email. Keep the pre-filled prices — they're market-rate defaults.

Read this once, because it changes how you use everything else. This kit is sold non-exclusively. Anyone can buy it. There is no territory, we never promised you one, and we couldn't enforce it if we had. What you bought is the machine — and the whole point is that you put your own name on it.

That is not a consolation prize, it's the instruction. Your edge is your name, your city and the calls you make, not owning files nobody else can get.

So don't stop at the config file. Over your first week, replace:

  • Your agency name and domainconfig/business.yaml feeds it into every document automatically. Pick a name that isn't "[City] Web Co" if you can.
  • Your prices — the defaults are market rates, not a price list you're bound to. office/quoting-guide.md shows you how to move them.
  • Your storefront copyagency-site/ ships finished words so you have something to deploy tonight. They are a starting draft, not your voice. Rewrite the headline and the about section at minimum (see agency-site/README.md, "Make it yours").
  • Your call openersales/call-script.md is a script to learn, then loosen. It should sound like you by call fifty.
  • The demo names — Ironwood Plumbing Co. and everything in the EXAMPLE/ folders are invented placeholders for you to look at. Never deploy one, never send one to a prospect.

Ask your Claude: "help me rewrite the agency site copy in my own voice" — it has all of this in front of it.

3. Get a business phone number (~20 min)

docs/your-phone-setup.md. Do not run hundreds of cold calls a month from your personal cell. US carriers watch calling patterns, and a personal number that suddenly makes 25 outbound calls a day starts showing up on people's screens as "Spam Likely" — after which your answer rate collapses and nothing tells you why. A dedicated line costs $0–$20/month and takes twenty minutes.

That page also covers putting your agency name on your caller ID, and ramping your call volume so you never trip the filters in the first place.

4. Build your list (~30–90 min — the real work of day one)

Tell Claude: "Run the lead scraper for my city."

Or run it yourself from this folder:

cd scraper
python3 -m pip install -r requirements.txt
python3 leads.py --city "Your City, Your State"

Free data, no API key, no account, no card — and here's the honest catch. The data is OpenStreetMap, which is drawn by volunteers, so coverage is a lottery by area. Some towns hand you a hundred plumbers; some hand you one. The scraper measures what your town gave you and tells you which fix you need — and there is a fix that stays free and cardless, in every city (step 4b below). Nothing on day one requires you to enter payment details anywhere.

Out comes scraper/leads.csv, ranked by how badly each plumber's web presence needs you.

Where this kit is strong and where it's thin (measured runs, in docs/FIELD_TEST.md):

Your area What one keyless command gave us What day one looks like
Big Sun Belt metros (measured: Mesa AZ, Phoenix + Gilbert AZ) 120 rows / 17 dialable for Mesa (three runs over two days: 107/17, 114/15, 120/17 — map data and free servers both move); 150 rows / 25 dialable across Phoenix + Gilbert Add a neighbouring town or two and you're calling on day two
Mid-size and older metros (measured: Chattanooga TN, Toledo OH) 2 rows / 1 dialable; 3 rows / 1 dialable A map desert. Budget an extra hour on day one for step 4b

We can't tell you in advance which one your town is — that's the whole point of running it. Neither can anyone else selling you a scraper.

📺 Want to know what it should look like before you run it? docs/what-you-will-see.md prints every screen of this first hour in advance — real output from real runs — with a note beside each one saying whether it's good news, bad news, or just noise. It is the closest thing to watching over someone's shoulder, and it exists because the most expensive mistake on day one is assuming you broke something that's working fine.

The number that matters is the one in the box at the end: DIALABLE NOW. That's leads that have a phone number and look like real contractors. Row count is not a call list — a row without a phone number is not a phone call.

You want about 40 dialable leads — roughly a week of calling at 25 dials a day, once you allow for no-answers. Then:

  • Got 40+? You're done with day one. Go to step 5.
  • Got fewer? Completely normal, and not your fault — free map coverage varies enormously by area. Go to step 4b; the scraper also prints your next moves with the exact commands.

4b. If your city came back thin (~1 extra hour, still free)

The scraper knows what it found and orders the fixes to fit your run. All of these are free; only the last one needs a card.

  1. Add neighbouring towns to the same command — two minutes, still free: python3 leads.py --city "Your City, ST" --city "Next Town, ST". Ask Claude which towns those are.

  2. Work the phone-lookup list the scraper wrote for you (scraper/lookup-list.md) — about a minute per number, and your Claude can do the searching.

  3. Build 20 by hand — the no-card rescue path. Search "plumber near [your city]" in Google Maps, copy names and numbers into a copy of scraper/hand-list-template.csv. Only the name column is required — but paste the website in too. Three seconds per row off the Maps listing, and it is the difference between a lead that gets fetched, measured and scored, and one that stays UNVERIFIED. Then:

    python3 leads.py --city "Your City, Your State" --merge-csv my-list.csv
    

    They get merged, de-duplicated and ranked exactly like scraped leads — and any row where you filled in the website gets that site fetched and scored like any other. Rows with the website left blank cannot be checked (there is nothing to fetch), so they are marked unverified and sorted below the measured leads rather than above them. That is the honest ranking: an unchecked row is not a hot lead, it is a row nobody has looked at. About an hour for twenty, and your Claude can do the searching part for you. This works in every city, needs no key and no card, and it is the path we can actually promise.

  4. Only then, if you want the faster route: the optional Google Places source — about ten minutes, and it does require a Google Cloud project with billing enabled (a card on file, with a free monthly allowance). Its rows arrive with phone numbers. Be aware we ship this honestly: we have not tested it against a live key, because we don't put our billing account in your kit. The keyless path keeps working regardless.

Full detail: scraper/README.md, "When your city runs thin".

⚠️ One thing you must not do. A blank website column means the map doesn't know of a site, not this business has no website. Never open a call with "I looked you up and couldn't find you" unless you personally looked them up. It takes ten seconds and your Claude does it for you. Say it to a plumber who has a perfectly good website and the call is over in four seconds.

5. Know the rules before you dial (~20 min, once)

docs/rules-of-the-road.md — do-not-call, what's different about texting, what has to be in a commercial email, and whether your state wants sales callers registered. Plain English, no lawyer-speak. Twenty minutes you never have to spend again.

The one line you already know and never break: anyone who says "stop calling" goes into office/do-not-call.md immediately and is never contacted again on any channel. Forever. No spam, no fake reviews, deliver what you sell. Your name is the business — protect it.

6. First calls (tomorrow morning)

Tell Claude to run prompts/01-prep-calls.md — it turns the CSV into a ranked call list with a personalized, verified opener for every lead. Print sales/call-script.md, keep sales/objections.md open next to it, and dial between 9 and 11:30am. Aim for 10–15 dials on your first morning while your number is new, working up to 25–30 over two weeks (that ramp is in docs/your-phone-setup.md).

The script carries you. Most won't answer, a few will talk, and that's exactly on track.


When someone says yes

office/service-agreement.md — one page, plain language, signed before you build: what you're building, what you're not, one included revision round, who owns what, the money, and no promises about results. Signed agreement plus deposit is what starts a build. Your Claude fills it in from the intake answers in about thirty seconds. There's a filled example to look at first at office/EXAMPLE-service-agreement.html.

Then fulfillment/delivery-checklist.md takes you from "yes" to a live site and a paid invoice.

Where everything lives

README.md in this folder is the one-page map of every folder — worth two minutes before you start. Your Claude has a deeper version in CLAUDE.md and already knows it by heart.

Your full first week, day by day, is playbook/week-1.md. Setting up the business side properly — bank account, EIN, sales tax, getting paid — is docs/setting-up-shop.md, and none of it blocks your first call.

From here on, just ask your Claude: "what's next?"

Go build the list.

📄 docs/connect-claude.md

Open file

Connect Your Claude — Zero-Knowledge Guide

This kit is built to be run by your Claude. You plug in your own Claude account, open this folder, and the operator brain (CLAUDE.md) loads automatically — your Claude instantly knows the business, the tools, and the day-one plan.

Never touched a terminal? That's fine. Every step is written for you. Total time: about 25 minutes. (There's also a no-terminal fallback at the bottom.)

The five things you're installing/setting up, so nothing is a surprise:

Step What Why Time
1 A Terminal window The place you type commands 1 min
2 Claude Code Your Claude, running in this folder 5 min
2.5 Python The lead scraper is a Python program. Nothing else in the kit needs it. 5 min
3 A Claude account Claude Code needs to log in 5 min
4–5 Open the folder, verify Proves the operator brain loaded 5 min

Nothing here costs money except your Claude plan. Python and Claude Code are free downloads.


Step 1 — Open a Terminal

  • Mac: press Cmd + Space, type Terminal, hit Enter.
  • Windows: press the Windows key, type PowerShell, hit Enter.

A window with a text prompt appears. That's it — you type commands, it does them.

Step 2 — Install Claude Code

Claude Code is Anthropic's official command-line app. It's what lets your Claude read this folder, run the scraper, and build client sites.

Mac (paste into Terminal, press Enter):

curl -fsSL https://claude.ai/install.sh | bash

Windows (paste into PowerShell, press Enter):

irm https://claude.ai/install.ps1 | iex

Prefer an installer-manager? Mac Homebrew users can run brew install --cask claude-code; Windows users can run winget install Anthropic.ClaudeCode.

When it finishes, check it worked:

claude --version

You should see a version number followed by (Claude Code). If the install command errors, close the terminal, open a fresh one, and try again — or see Anthropic's install troubleshooting at https://code.claude.com/docs (search "troubleshoot installation").

Windows note: installing Git for Windows is recommended — it gives Claude Code a proper shell for running the scraper.

Step 2.5 — Install Python (do NOT skip this)

The lead scraper (scraper/leads.py) is a small Python program. Python is a free programming language runtime. Your computer probably does not have a usable one yet, and this is the single most common place people get stuck. Five minutes now saves you an hour tomorrow.

First, check what you already have

Mac / Linux:

python3 --version

Windows (PowerShell):

py -3 --version

What you want to see: Python 3.9.6, Python 3.12.4 — any 3. number is fine.

What means "not installed":

  • command not found / not recognized → install it below.
  • On Windows, a Microsoft Store page opens, or you see nothing at all → that's the fake python3 stub Windows ships. It is not Python. Install it below.
  • Python 2.7.x → too old. Install it below.

Install it — Mac

Easiest and most reliable: download the official installer.

  1. Go to https://www.python.org/downloads/macos/
  2. Click the big yellow "Download Python 3.x.x" button.
  3. Open the downloaded .pkg file and click Next through the installer.
  4. Close your Terminal window and open a fresh one (it only notices new programs on startup).
  5. Check again: python3 --version

If you already use Homebrew, brew install python works too.

If a box pops up saying "The 'python3' command requires the command line developer tools" — that's macOS offering you Apple's own Python. You can click Install (it's a large download, 10–20 minutes, and it works fine), or click Cancel and use the python.org installer above instead. Either road ends in the same place. The python.org installer is faster.

Install it — Windows

Open PowerShell and paste:

winget install Python.Python.3.12

Then close PowerShell and open a fresh one, and check:

py -3 --version

No winget on your machine? Download the installer from https://www.python.org/downloads/windows/ instead — and on the first installer screen, tick the box that says "Add python.exe to PATH" before clicking Install. That box is the difference between this working and not working.

⚠️ Windows: use py -3, not python3. Everywhere in this kit you see python3 something, a Windows CEO types py -3 something. Example: py -3 leads.py --city "Mesa, Arizona". If you forget, Windows opens the Microsoft Store instead of running anything. Your Claude knows this — if a command misbehaves, tell it "I'm on Windows" and it will fix the command for you.

Install it — Linux (Ubuntu / Debian)

sudo apt update && sudo apt install python3 python3-pip python3-venv

Now install the one thing the scraper needs

From the kit folder:

cd scraper
python3 -m pip install -r requirements.txt

(Windows: py -3 -m pip install -r requirements.txt)

Three outcomes:

1. It works. You'll see "Successfully installed requests..." or "Requirement already satisfied". Done — go to Step 3.

2. You see error: externally-managed-environment. Nothing is broken. Newer Macs and Linux systems protect the system Python and want programs kept in their own little box. Make the box — copy these three lines one at a time:

python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -r requirements.txt

(Windows: py -3 -m venv .venv then .venv\Scripts\activate then py -3 -m pip install -r requirements.txt)

Your prompt now starts with (.venv). That's correct — it means the box is open.

Remember this: every future time you open a new terminal to run the scraper, run source .venv/bin/activate from the scraper folder first (Windows: .venv\Scripts\activate). If you forget, you'll get "Missing dependency: requests" — that's the reminder, not a failure. Your Claude will do this for you automatically once you tell it "I set up a venv in the scraper folder."

3. Any other error. Copy the whole error text, paste it to your Claude, and say "this is the error I got installing the scraper's dependency — walk me through fixing it, one step at a time." It has the full kit in context and can see your machine.

Prove it works before you move on

python3 leads.py --help

(Windows: py -3 leads.py --help)

If you get a page of options starting with usage: leads.py, Python is done and so is the hardest technical part of this whole kit. Everything after this is typing and talking.

Step 3 — Get an Account (Two Roads, Pick One)

Claude Code needs a Claude account. Either road works for this kit:

Road A — Claude subscription (simplest). If you have (or sign up for) Claude Pro, Max, Team, or Enterprise at https://claude.com/pricing, you log in with that account and you're done — no API key, no separate billing. Usage is included in your subscription's limits. Recommended for most CEOs.

Road B — Anthropic API key (pay as you go).

  1. Go to https://console.anthropic.com and create a Console account.
  2. Add a small amount of prepaid credit ($5–$10 is plenty to start).
  3. You can simply log in with this Console account when Claude Code asks (easiest), or create a key under API Keys in the Console and set it as the ANTHROPIC_API_KEY environment variable if you know what that means. If you don't — just log in; Claude Code handles it.

Step 4 — Open This Kit Folder and Log In

In your terminal, move into the kit folder, then start Claude:

cd path/to/plumber-agency
claude

Replace path/to/plumber-agency with wherever you unzipped this kit. Easy trick: type cd (with the space), then drag the kit folder from Finder/File Explorer onto the terminal window — it pastes the path for you. Press Enter.

The first time you run claude, it prompts you to log in. Follow the prompts — a browser opens, you sign in with your subscription account (Road A) or Console account (Road B), and you're back in the terminal. You only do this once; to switch accounts later, type /login inside Claude Code.

Step 5 — Verify the Brain Loaded

You should see the Claude Code prompt with this folder as the working directory. Now the moment of truth. Type:

what business is this?

A connected Claude answers something like: "This is your plumber web agency — you sell professional website builds ($500–$1,500) to local plumbing businesses, with missed-call textback, review engine, and care plan upsells. Want to start with the day-one sequence?"

If you get that: you're live. Say let's do day one and Claude takes it from there (setup, config/business.yaml, first scrape — the full sequence is in playbook/week-1.md).

If Claude answers generically instead: you're in the wrong folder. Type /exit, then redo Step 4 making sure you cd into the folder that contains CLAUDE.md before running claude.


Troubleshooting

Symptom Fix
claude: command not found Close the terminal, open a new one, retry. Still failing → redo Step 2.
irm is not recognized (Windows) You're in CMD, not PowerShell. Open PowerShell (prompt starts with PS).
Login loop / wrong account Type /login inside Claude Code and pick the right account.
Claude doesn't know the business Wrong folder — cd into the folder containing CLAUDE.md, run claude again.
python3: command not found / 'python3' is not recognized Python isn't installed (or on Windows you're hitting the fake Store stub). Do Step 2.5. Windows: use py -3, not python3.
The Microsoft Store opens when you type python3 That's Windows' placeholder, not Python. Do Step 2.5 and use py -3 from then on.
error: externally-managed-environment Your system Python is protected. Step 2.5, outcome 2 — the three venv lines. Nothing is broken.
Missing dependency: requests Either you skipped the pip install line in Step 2.5, or you made a venv and this terminal hasn't opened it. From scraper/: source .venv/bin/activate (Windows: .venv\Scripts\activate).
"The 'python3' command requires the command line developer tools" (Mac) Apple offering its own Python. Click Install and wait, or Cancel and use the python.org installer in Step 2.5.
pip: command not found Use python3 -m pip ... (with the -m) rather than plain pip. Every command in this kit already does.
Any Python error not listed here Copy the entire error message, paste it to your Claude, add "walk me through this one step at a time." Do not retype it from memory — the exact text is what it needs.
Your answer rate on calls collapsed after a good first week Your number may have been flagged by carriers. docs/your-phone-setup.md, "If your number gets flagged".

Fallback — No Terminal At All (claude.ai in the browser)

If the terminal truly isn't happening today, you can still run most of the business from https://claude.ai in a normal browser. It's clunkier — browser Claude can't run the scraper on your computer or edit files directly — but it works:

  1. Sign in at https://claude.ai (any paid plan).
  2. Start a new chat (a Claude Project is even better — you can add the kit files once and every chat in the project knows them).
  3. Upload or paste CLAUDE.md from this kit as your first message, with: "You are the operator brain for this business. Read this and act accordingly."
  4. Verify the same way: ask "what business is this?" — you should get the plumber-agency answer.
  5. Upload the other files as you need them: sales/call-script.md before calling, prompts/01-prep-calls.md plus your leads.csv for call prep, fulfillment/ templates when you close a client.

For the lead list without the scraper: openstreetmap-based lead data needs the terminal, so in the browser either ask Claude to help you build a list by hand from Google Maps searches ("plumber near [your city]" — name, phone, website into a spreadsheet), or get a terminal-comfortable friend to run Day 1 for you once. Twenty hand-gathered leads are enough to start calling.

Honest recommendation: do the 15-minute terminal setup. The whole point of this kit is that your Claude runs the machine — scraper, files, pipeline, builds. The browser fallback keeps you moving today; Claude Code is the business.

📄 docs/FIELD_TEST.md

Open file

FIELD TEST — plumber-agency kit

This is the kit's lab notebook. It is written for you, the buyer, not for us — which means it includes the runs that went badly. If a field test only ever says PASS, it isn't a field test, it's an advert.

Read the newest section first — v6, at the bottom. It has the current numbers, and it is the one where an outside Claude was dropped into the delivered zip with no context and found three things that would have hurt a real buyer. Everything above it is history, kept so you can see what changed and why. Where an older section and v6 disagree, v6 is right — including the "PASS" verdicts, which were written by the people who built the kit.


Current headline (v3.1, 2026-08-03)

The fulfillment machine: PASS. Storefront, client website template, delivery checklist, invoices, agreement, upsell modules, prompts — all built, all run, all internally consistent.

The lead engine: PASS, with a coverage caveat you should know before you start. It works, it is honest about what it found, and it now automatically searches the surrounding metro area when your city is short on phone numbers — worth about 4x the dialable leads in the reference city. But free map coverage genuinely varies by region, and in a thin area you will need an extra step before day two — either the phone-lookup list, a hand-built list merged in with --merge-csv (free, no key, no card), or the optional Google Places source (faster, needs a card). The scraper tells you which one your run needs, and it recommends the free one first.

What that means for you: day one is building the list, day two is calling it. If anyone tells you a scrape hands you a finished call list in ten minutes, they haven't run one.


v1 — 2026-08-01

Tester: a fresh Claude playing the buyer's AI — cold, skeptical, following START_HERE.md and CLAUDE.md only, exactly as a new CEO's Claude would. Verdict: PASS (with the fixes and notes below applied).

(Note from v3: the score for "no website in the map data" was 100 in this version and is now 90, with wording that tells you to verify before you claim it on a call. Read "100" as "90" everywhere below.)


What was actually run

1. Day-one scraper run — real cities, real leads

Executed the exact documented commands from START_HERE.md / CLAUDE.md:

cd scraper
python3 -m pip install -r requirements.txt
python3 leads.py --city "<city>"
  • Install worked first try on a stock macOS Python 3.9 (no venv needed; the venv fallback in scraper/README.md is documented for locked-down machines).
  • Mesa, Arizona (the spec's reference city): 43 unique named leads written to leads.csv. 38 with no website (score 100), 5 with a website, 5 with a phone number in the map data. Columns exactly as documented in scraper/README.md. Sorted hottest-first. Real businesses, real addresses, real coordinates.
  • Chattanooga, Tennessee (independent mid-size test city): the run completed cleanly but returned 1 lead — verified by hand against the Overpass API that OpenStreetMap genuinely has only one plumber tagged in all of Hamilton County. Not a scraper bug: an OSM coverage desert. See "Open gaps" below.
  • Hamilton County, Tennessee (README's county fallback): one run died politely after 4 retries across 3 mirrors (Overpass free servers busy — the error message and "wait 5 minutes" advice shown to the user are accurate); a manual retry on a mirror succeeded ~10 minutes later. The retry/mirror logic works as designed.
  • The generated leads.csv test artifact was deleted after the test so the kit ships clean; the buyer's first run creates their own.

2. Agency storefront build

Filled config/business.yaml with a test identity, ran cd agency-site && python3 fill.py:

  • Built dist/ with zero unfilled {{tokens}}, correct comma-formatted prices from the yaml's nested pricing: block, and the documented "refuses to build if identity keys are missing" behavior.
  • Config was restored to pristine blank and dist/ deleted afterwards.

3. Fulfillment delivery checklist walked against the template

Every file and section the checklist references exists and matches:

  • fulfillment/website-template/ = index.html (324 lines) + styles.css (470 lines) + script.js (85 lines). The page is a complete, professional plumber site — emergency call strip, hero, trust bar, 8 service cards, why-us, verbatim-reviews section, service-area chips, Formspree lead form with honeypot, sticky mobile call bar. Not a stub.
  • All 27 distinct {{PLACEHOLDER}} tokens found in the template files are documented in website-template/README.md's table — no undocumented tokens, no documented-but-missing tokens. {{FORMSPREE_ID}} present in the form action as described. (Note from v3.1: there are now 34 — the seven claim tokens were added when the hardcoded client claims came out.)
  • --navy and --accent CSS variables exist at the top of styles.css exactly where the checklist's brand-color step says.
  • The checklist's grep -rn "{{" verification step, the office/clients/<client-slug>/site/ copy path, and the references to ../prompts/04-invoice.md and care-plan-sop.md all check out.

4. Prompts vs the real leads.csv

prompts/01-prep-calls.md described the CSV as containing "rating signals" — a column that does not exist (OSM has no ratings). Fixed (below). Prompts 02–05 reference only real columns and real paths.

5. Full path audit

Every path referenced in CLAUDE.md, START_HERE.md, all 5 prompts, the sales files, playbooks, and both READMEs resolves to a file on disk. The only non-existing paths are the runtime-generated ones (office/clients/, office/invoices/, agency-site/dist/, scraper/leads.csv), all clearly documented as generated. office/do-not-call.md ships seeded. No income promises anywhere (scanned); all dollar figures are framed as market rates.


What passed

  • Day-one flow is genuinely followable from START_HERE.md alone in under 30 minutes: install → scrape → real CSV → prompt 01.
  • Scraper: keyless, polite (mirror rotation, backoff, 0.7s crawl delay), honest error messages, output matches its documentation.
  • Both HTML products (agency storefront and client template) are complete, mobile-first, professional pages.
  • fill.py works end-to-end against the shipped business.yaml schema, including the nested pricing keys.
  • Delivery checklist, template README, and prompt 03 all agree on paths and placeholder workflow.
  • Guardrails (do-not-call, no spam, no fake reviews, honest-about-AI, no income promises) are written into CLAUDE.md and echoed at every point of use — script, outreach, fulfillment, invoicing.

What was fixed during the test

  1. prompts/01-prep-calls.md — replaced the phantom "rating signals" column reference with the real column list, and changed the ranking tiebreaker from "some reviews, not zero" (impossible — no reviews column) to real activity signals (street address, opening_hours, specific category).
  2. prompts/01-prep-calls.md — added phone-lookup guidance: most score-100 leads have no mapped phone (Mesa: 5 of 43 had one), so the prompt now tells the buyer's Claude to look numbers up by name + city or mark them "look up before dialing", never guess.
  3. prompts/03-fulfill.md — step 1 implied reviews/services live in leads.csv; now correctly attributes them to the research brief.
  4. scraper/README.md — thin-results troubleshooting now says plainly that even mid-size cities can have near-zero OSM coverage (Chattanooga: 1 plumber mapped county-wide) and adds the hand-gathered-list fallback.

Open gaps (honest, not blockers)

  • OSM coverage lottery. The kit's core lead source is only as good as local OSM mapping: Mesa = 43 leads, Chattanooga = 1. The README now warns about this and gives workarounds (county scrape, neighboring cities, hand-gathered lists), but a buyer in a coverage desert has a slower day one. Structural recommendation for a future rev: an OPTIONAL, clearly-marked Google Places enrichment path as a second lead source. (Shipped in v2, and in v3 the scraper recommends it by name when your area turns out to be one of these deserts.)
  • Phone coverage. Even in a good city, most no-website leads have no mapped phone; the "25 dials tomorrow" pace requires the lookup step now documented in prompt 01 and the scraper README. Works, but it's manual.
  • Overpass availability. Free shared servers; one county-size query needed a second attempt ~10 minutes later. The retry logic and user-facing messaging handle it honestly, but day-one timing can slip by minutes.
  • Formspree/Netlify are third-party free tiers. Limits (50 submissions/mo, drag-and-drop deploys) are correctly documented, but tier terms can change under the kit.

Bottom line (v1): a fresh Claude with zero context ran day one from the shipped docs alone, pulled 43 real leads in the reference city, built the storefront with one command, and found every file the fulfillment flow depends on.

Correction added in v3: this bottom line was too clean. 43 leads in Mesa was a row count, and only 5 of those rows had a phone number — which is not a week of calling, and the sentence "the kit does what the box says" glossed over exactly the gap the open-gaps section above had already admitted. See v3 for what that number really is and what was done about it.


Addendum — scraper v2 (2026-08-01)

Scraper upgraded to v2 to close the open gaps above. What we ran this time:

  • Multi-city merge, live and keyless: python3 leads.py --city "Mesa, Arizona" --city "Gilbert, Arizona" → 43 + 17 raw rows, 57 unique leads in one CSV (3 cross-city duplicates merged by phone). Full enrichment ran; 49 leads scored 100 (no website), 8 phones ready to dial.
  • Phone-lookup helper: the same run wrote lookup-list.md with all 49 hot no-phone leads, each with a working pre-built Google Maps search link and the manual + Claude-assisted lookup flow. Verified the links and the file format by hand.
  • CSV schema: backward-compatible — the original 14 columns are unchanged and in the same order; one new column, source (osm / google / osm+google), added at the end. prompts/01-prep-calls.md updated to match.
  • Merge/dedupe engine: 26 synthetic unit checks, all passing — phone-match across sources, name-match within a city, name-collision protection (same name + different phones = two businesses; same name in different cities = kept separate), source labeling, Google Places (New) response parsing, pagination via nextPageToken, and bad-key error handling.
  • Google Places source (OPTIONAL): NOT tested live. It needs a billed API key we don't ship with the kit. The request path is unit-tested against the documented API format (mocked responses) and fails safe: a rejected key prints specific checks and points back to the keyless default. The README says all of this to the buyer in plain words.
  • No-key guard: --source google without a key exits with exact instructions (flag, env var, README section) — verified live.
  • All test CSVs and lookup lists were generated outside the kit folder; the kit still ships clean.

v2 — 2026-08-01

Tester: a fresh, skeptical Claude doing a full buyer dry run against the upgraded kit (upsell modules, variant B storefront, office/ back office, prompt 06). Python deps installed into a clean venv (the README's documented fallback path) — worked first try. Verdict: PASS (fixes below applied).

1. Scraper re-run — keyless path, multi-city merge, exactly as documented

  • Overpass had a genuinely bad night. Three consecutive runs on an independent city pair (Tulsa + Broken Arrow, Oklahoma) died politely: overpass-api.de returned 504 instantly, and the other two mirrors' front proxies cut the connection at ~60s on the heavy city-area query. Every failure printed the documented message ("free shared service… wait 5 minutes and run the same command again") — the failure path a buyer would see is accurate and calm. Verified by hand (curl per mirror) that this was server-side load, not a scraper bug: a tiny query succeeded on 2 of 3 mirrors at the same moment.
  • The documented retry advice then worked: python3 leads.py --city "Mesa, Arizona" --city "Gilbert, Arizona" → Mesa's query failed once, mirror rotation recovered on attempt 2, and the run completed: 43 + 17 raw → 57 unique leads in one leads.csv (3 cross-city duplicates merged), 49 score-100 no-website leads, 8 phones ready to dial, per-lead city values intact (41 Mesa / 16 Gilbert after merge). Real businesses, sorted hottest-first. Matches the v1 addendum numbers exactly — the merge engine is stable.
  • lookup-list.md was written alongside: all 49 hot no-phone leads, each with a pre-built Google Maps search link, manual + Claude-assisted flows, and the never-dial-unverified rule.
  • Both artifacts deleted after verification; the kit ships clean.

2. New-module walk — every file opened, every path chased

  • fulfillment/missed-call-textback/ (4 files): README (pricing + monthly service + rules), sell-in one-pager, two-path setup SOP (OpenPhone / Google Voice — honest about GV's manual limitation), text templates with baked-in rules (name the business, callers only, STOP = gone, one follow-up max). Complete, professional, no income promises ("can pay for itself", never "will").
  • fulfillment/review-engine/ (7 files): README, one-pager, ask-flow, monthly cadence, bad-reviews playbook (the no-fake-reviews contract), QR card + generator. make_qr.py was RUN (segno in the venv, as its header documents): produced a valid 396×396 PNG from a test review link. qr-card.html renders 8 print-ready cards; placeholders documented in its header.
  • office/ (7 shipped files): tracker CSV (header-only seed) + tracker-guide (stages/columns match prompt 06 and prompt 04 exactly), do-not-call seed, invoice master (md + printable HTML), proposal one-pager HTML, onboarding email sequence. Professional, print-tested layouts, market-rate framing throughout.
  • agency-site/variant-b/: token set is IDENTICAL to variant A (10 tokens, verified by extraction). Both fill.py scripts were RUN against a test-filled business.yaml: both built dist/ with zero unfilled tokens and correct comma-formatted prices; variant B's missing-identity refusal verified (exits with the exact-key message on a blank config). Both forms carry the same Netlify Forms wiring (website-review + honeypot). Config restored pristine, dist/ deleted.
  • Path audit: every path referenced across CLAUDE.md, START_HERE.md, prompts 01–06, sales, playbooks, module READMEs and both HTML masters resolves to a real file; the only non-existing paths are the documented runtime-generated ones.

3. Prompts 01–06 vs the real leads.csv

Column list in prompt 01 matches the generated CSV exactly (15 columns, source last). Prompts 02–06 reference only real columns, real config keys (pricing.textback_monthly, review_engine_monthly, care_default all exist in business.yaml), real tracker stages/offers, and real files.

What was fixed during this test

  1. office/invoice-template.md{{PROJECT_DESCRIPTION}} is used by invoice.html but was missing from the token reference. Added.
  2. office/invoice.html + office/proposal-onepager.html — the header comments contained a literal {{TOKEN}}, which made the documented "grep for leftover {{ comes back empty" verification impossible to pass. Reworded to "placeholder token".
  3. fulfillment/delivery-checklist.md — the template-copy step now removes README.md from the client copy (its {{token}} examples broke the same grep check and would have been deployed with the client's site). prompts/03-fulfill.md step 4 mirrors this.
  4. prompts/03-fulfill.md — client folder paths said office/clients/<business-name>/; CLAUDE.md and the delivery checklist say office/clients/<client-slug>/. Aligned to <client-slug>.
  5. playbook/growth.md — "pays for the plan the first time it catches a job" tightened to "can pay for the plan", matching the kit's own can-never-will rule.

Open gaps (honest, not blockers)

  • Overpass availability is the kit's single point of day-one friction. v1 saw one failed run; v2 saw three in a row before a clean success. The buyer-facing messaging is honest and the retry worked, but a rough night on the free mirrors can stall day one by 15–30 minutes. Future rev idea: cut the Overpass query time budget so a mirror whose proxy drops the connection at ~60s still has a chance to answer, and/or chunk very large cities. (Correction, 2026-08-03: this note originally told you to "drop [timeout:90] to ≤60s". There is no [timeout:90] in the code and there never was in the shipped version — scraper/leads.py builds both queries with [out:json][timeout:120], in build_query() and build_radius_query(). The idea stands; the number in the original note was wrong, and a doc that names a value the code doesn't contain is worse than no note at all. Second correction, 2026-08-03: this paragraph cited "lines 308 and 344" — 344 was the closing bracket of the function, not the timeout, and line numbers move every time the file is edited. Function names don't, so it cites those now.)
  • Tulsa/Broken Arrow coverage unverified — the pair never got a server response, so this test says nothing about Oklahoma OSM coverage either way.
  • v1's standing gaps (OSM coverage lottery, phone-lookup manual step, third-party free tiers) are unchanged and still honestly documented.

Bottom line v2: the upgraded kit holds the bar. The scraper's happy path and failure path both behave exactly as the docs promise, the merge numbers reproduce, both storefront variants build clean from one config, every new module is complete and internally consistent, and the five defects found were paper cuts, all fixed in place.


v3 — 2026-08-03 — the lead engine got audited properly

Tester: a hostile outside reviewer who bought the kit, followed START_HERE.md literally, and ran the documented command against four real cities before writing anything down. Then a rebuild pass against what they found.

This is the section that matters, because it is the one where the kit's own field test had been flattering itself.

What the audit found (and it was right)

The v1/v2 tests reported rows. Rows are not phone calls. Re-run with the right question — how many of these can I actually ring today? — the reference city looked very different:

City Rows Leads with a phone number
Mesa, Arizona (our own reference city) 41 5
Tampa, Florida 6 3
Toledo, Ohio 1 0
Houston, Texas run failed, server 504s

Meanwhile the playbook was telling that same buyer to make 25 dials the next morning. Five numbers is not 25 dials. That gap was the single biggest problem in the kit, and the field test hadn't caught it because it had been counting the wrong thing.

Two other things the audit was right about, both since fixed:

  • month-1.md said the kit "pays for itself." That is an income claim and it should never have shipped. Removed, along with two more in growth.md. The rest of the kit had held that line correctly for months; those two files hadn't been held to it.
  • A blank website column was being reported as "no website at all" — a flat statement of fact generated from missing data, which the call script then put in the buyer's mouth on call #1. Now scored 90 (not 100) with the reason text "no website found in map data — VERIFY by searching their name before you dial", and prompts 01 and 02 refuse to write a "no website" opener for a lead nobody checked.

What was rebuilt, and what it measures now

Three changes to scraper/leads.py, all verified on live runs today:

1. The headline is DIALABLE NOW, and so is the thin-city trigger. The run prints leads-with-a-phone-number in a box, and the automatic metro widener now fires on that number instead of the row count. This matters more than it sounds: Mesa returns 43 rows inside the city limits and would have sailed through any row-based check, while having four phone numbers in it.

2. The metro widener asks a cheaper question. It searches a box around the city centre rather than a circle. Measured on the identical Mesa search:

Search shape Results Time Reliability
Circle (around:) — the old way 111 ~90s failed outright more often than it worked
Box — the new way 113 11s worked first try on the primary mirror

3. A busy server no longer throws away your run. Previously, a four-city run where city three timed out exited with an error and lost everything already fetched — eight minutes of waiting for nothing. Now that city is skipped with a plain-English message, the CSV is still written from the cities that worked, and a note tells you to rerun later to pick up the rest. Big cities that blow the servers' time budget also get one automatic retry with a lighter version of the same query.

Result on the reference city, same command, same afternoon:

BEFORE:   41 rows,   5 dialable
AFTER:   107 rows,  17 dialable

That is roughly 3.4x the actual phone calls from one command, with no key, no account, and nothing extra for the buyer to do or know.

And the remedy the kit tells you to use — does it actually work?

Fair question, since the whole thin-city plan rests on "add neighbouring towns and rerun". So we ran it, live, on a deliberately awkward mix — one map desert plus two real cities:

python3 leads.py --city "Toledo, Ohio" --city "Phoenix, Arizona" --city "Gilbert, Arizona"
City Contributed
Toledo, Ohio 1 dialable (the desert, as expected)
Phoenix, Arizona 21 dialable
Gilbert, Arizona 14 dialable
Merged 212 rows, 31 dialable

Three things worth reporting from that run. Two were good news. One was a bug we had just introduced ourselves.

  • Phoenix's main query failed four times in a row — the free servers would not answer the full question for a city that size. The automatic lighter retry then succeeded and returned 35 businesses. Under the old code the entire three-city run would have exited with an error at that moment and thrown away Toledo's results too. That is precisely the failure the "skip and carry on" change was written for, and we watched it save a run.

  • 🐛 We then counted the duplicates, and there were 63 of them. Once the metro widener is on, neighbouring towns' searches overlap enormously — Phoenix's 15-mile box and Gilbert's 15-mile box are mostly the same square of desert. The same plumber therefore arrived twice, once labelled "Phoenix, Arizona area" and once "Gilbert, Arizona area", and the old merge rule treated two different city labels as two different businesses. That means the CEO would have rung the same plumber twice — exactly the sort of thing that makes a small agency sound like a robot.

    Fixed: the merge now checks the map's own record ID first, then the phone number, then name-within-city, then name-within-two-miles regardless of what the city label says. Seven unit tests cover it, including the two cases that must NOT merge — same name with different phone numbers, and same name twenty miles apart. Those are real separate branches and real separate calls.

    Verified live on the worst case, Phoenix + Gilbert (two towns whose metro searches almost entirely overlap): 63 duplicate groups became 4, and all four survivors are genuine separate branches five to twenty miles apart. Final: 150 rows, 25 dialable.

  • 25 dialable from two towns is still under the 40 the kit asks for. Add a third and fourth town, or spend half an hour on the lookup list, and you're there. We are not going to round that up for you.

What is still true, and you should hear it plainly

17 is not 40. The kit says you want about 40 dialable leads before you start a week of calling, and the reference city does not hand you that from a single keyless run. It hands you most of a morning. Getting from there to a week is the phone-lookup list, neighbouring towns, or the optional Google Places source — and the scraper now tells you which one your run needs rather than making you guess.

Toledo, Ohio is still a map desert, and no amount of engineering fixes that. Re-run today after the rebuild: 3 businesses in a 30-mile box, 1 with a phone number. Toledo obviously has hundreds of plumbers; OpenStreetMap, which is drawn by volunteers, knows about three. Those are different facts and the kit now says so out loud. When the scraper detects this case — it already swept your metro and still came back nearly empty — it stops recommending "try the next town over" (the next town is in the same blank patch) and puts Google Places at number one instead, with the exact command.

Free map servers have bad afternoons. During this test session overpass-api.de returned 504 repeatedly, one mirror cut connections at 90 seconds, and a third answered the same query in 11. The kit rotates mirrors, backs off, retries with a lighter query, and now degrades to "skip that city and keep going" instead of dying. It cannot make the free internet fast.

Honest scoreboard

Verdict
Fulfillment machine (site templates, delivery, agreement, invoices, modules) PASS — complete, run, internally consistent
Lead engine, well-mapped city PASS — one command, ~17 dialable; two cities merged (Phoenix + Gilbert, after the dedupe fix), 150 rows / 25 dialable; honest about the shortfall either way. (The "31 dialable" from the three-city run above is a pre-dedupe-fix number and includes duplicates — 25 is the one to quote.)
Lead engine, map-desert city PASS with a required extra step — the run tells you it's a desert and points you at the fix; expect ~10 extra minutes on day one
Integrity / no income claims PASS — three violations found and removed; whole kit re-scanned
Day-one timing claim PASS — every file now says day 1 builds the list, day 2 calls it

Bottom line v3: the fulfillment half of this kit was always strong. The lead half was over-reported, and has now been measured on the metric that matters, rebuilt around it, and documented with the numbers left in — including the ones that aren't flattering. If your city comes back thin, that is a fact about volunteer map coverage in your area, not a verdict on your business, and the kit will tell you exactly what to do about it before you waste a morning.


v3.1 — 2026-08-03 — buyer's defect list, closed

Tester: a skeptical buyer who actually ran the kit and scored it 7/10, then a fix pass against every defect they filed. Everything below was reproduced on a stock macOS machine (system Python 3.9.6, pip 21.2.4) before it was fixed.

What they found, and what was done

  1. The claims gate existed; the claims were still hardcoded. CLAIMS.md documented 11 promises the client site makes, and both templates still stated all of them as fact — so a buyer who skimmed published them. Fixed at the source: the claims are now 7 {{CLAIM_*}} tokens (which fail the same grep -rn "{{" as every other placeholder) plus fenced CLAIM-BLOCKs for the ones that are whole cards and panels — 7 blocks in variant A, 10 in B, each with a delete-unless-confirmed comment. Second pre-launch check added: grep -rn "CLAIM-BLOCK" must also come back empty. Token parity between the two templates re-verified: 34 tokens each, identical sets.

  2. Voicemail / SMS / email had no VERIFIED-vs-UNVERIFIED variants. The call script shipped three openers; the asynchronous templates — which get used more, because most dials are no-answers — asserted "I looked at your website" for every lead, including the score-90 rows where no website is known to exist. Fixed: every day-1 template now ships in the same A/B/C versions, prompts/01-prep-calls.md writes the version letter onto each lead in the call list, and the pre-send checklist gates on it.

  3. Invented social proof. "Nobody has ever refused that", "the monthly report is why nobody cancels", "churn stays low anyway", "25 leads beat 200 sprayed", "same-day mockups close" — none of it observed, because nobody has run this agency for real. Fourteen statements across eleven files rewritten to state the reasoning instead of a result. LICENSE.md now says plainly that we field-tested the machine and have never sold a website to a plumber.

  4. The "free data, no API key" promise was only half true. Reproduced: python3 leads.py --city "Chattanooga, Tennessee"2 rows, 1 dialable, and the run's own #1 recommendation was Google Places — a path that needs a card and that we have never tested against a live key. Fixed two ways:

    • New free rescue path. --merge-csv FILE folds a hand-built list into the pipeline: deduped against the map data, websites fetched and scored, ranked into the same leads.csv. Only a name column is required; scraper/hand-list-template.csv ships blank. Measured on the same Chattanooga run: 1 dialable → 4, with one hand row filling in a missing phone number on a mapped lead instead of duplicating it (source column read osm+hand). It also runs with no --city at all.
    • The desert verdict now leads with it, with Google Places second and labelled as the card-required, untested-last-mile option. START_HERE.md, scraper/README.md, CLAUDE.md and docs/what-you-will-see.md all say which cities we measured as strong (Mesa 107/17, Phoenix+Gilbert 150/25) and which as thin (Chattanooga 2/1, Toledo 3/1).
  5. Two undocumented tripwires that print on a stock Mac before anything else: urllib3's NotOpenSSLWarning: ... LibreSSL 2.8.3 on every scraper run, and pip's WARNING: You are using pip version 21.2.4 plus Defaulting to user installation on install. Both reproduced here verbatim and added to docs/what-you-will-see.md with an explanation of why each is harmless.

  6. Doc-vs-code number drift. The v2 "open gaps" told you to drop the Overpass [timeout:90] — a value that appears nowhere; the code uses [timeout:120], in build_query() and build_radius_query() in scraper/leads.py. Corrected in place rather than quietly deleted. (Re-corrected 2026-08-03: the first correction cited "leads.py:308, :344", and :344 was the function's closing bracket. A correction with a wrong number in it is worth less than nothing, so both references now name the functions, which survive the next edit.) what-you-will-see.md printed the retry block as attempt 2/3 only; the main city query gets four goes and the wider sweeps three, so both shapes are now shown and explained. The v3 scoreboard's "three cities merged, 31 dialable" was a pre-dedupe-fix figure quoted as a headline; annotated with the post-fix number (25).

Open gaps after this pass (still honest, still not blockers)

  • Google Places remains untested against a live key. Unchanged, and now stated at every place the kit recommends it, with the free path offered first. It stops being a gap the day someone runs it with a real key.
  • The hand-built list is manual. An hour for twenty leads. Your Claude can do the searching, but somebody still has to look at Google Maps.
  • OSM coverage lottery, phone-lookup manual step, third-party free tiers — v1/v2/v3 gaps, unchanged and still documented.

v4 — parity levelling, 2026-08-03 (documentation + assets, no scraper changes)

A parity audit compared all three kits side by side. This kit was the reference standard on most dimensions and the source for the claims gate, the A/B/C outreach versions, the scraper verdict box and the phone-setup doc. It had two real gaps of its own, both now closed.

What was added

Added Why
fulfillment/website-template/EXAMPLE/ and website-template-b/EXAMPLE/ The buyer was being told to cold-call strangers and describe a website they had never seen finished. Both designs are now built out for a fictional client, Ironwood Plumbing Co., Mesa AZ
agency-site/EXAMPLE/classic/ and EXAMPLE/ironclad/ Both agency designs pre-built by the kit's own fill.py from a throwaway demo config, so the CEO chooses between two finished pages instead of two token files
docs/see-it-first.md The 20-minute browser tour of ten finished assets. No terminal, no Claude, no accounts
office/intake-form.html Section 4 is the claims gate in tick-box form — how the written yes required by CLAIMS.md actually gets collected
office/quoting-guide.md Base $500 plus named add-ons, capped at $1,500, with worked $500 / $950 / $1,500 examples
office/do-not-call.md rewritten Was 10 lines with no rules header. Now a proper ledger: seven rules, a table, and the back-up note

The EXAMPLE builds are deliberately imperfect

Ironwood was built from a pretend set of written intake answers that includes two Nos, so the EXAMPLE demonstrates the claims gate rather than just looking nice:

  • Claim 3 (24/7 emergency) came back NO. Design A lost the red emergency strip, the 24/7 stat tile and the emergency service card. Design B lost the top-bar note and service row 03, and the rows below renumbered.
  • Claim 9 (arrival window + a text) came back "we try to", which is a no. Design B's call card dropped a step and renumbered to three.
  • Claim 7 became "1-year workmanship warranty", not the bare word "guaranteed"; claim 10 became "by the next morning", not "within one business hour".

Both EXAMPLE folders pass grep -rn "{{" and grep -rn "CLAIM-BLOCK" clean.

Verified

  • Both EXAMPLE index.html files: zero unfilled tokens, zero surviving fences, zero occurrences of the deleted 24/7 claim.
  • Both agency EXAMPLE builds produced by running the real fill.py, not by hand.
  • office/intake-form.html: 6 balanced fieldsets, the copy-answers script intact, 5 tokens and no niche leakage from the file it was adapted from.
  • No income claims and no invented social proof introduced — swept by grep across the whole kit.

Still not tested live (unchanged)

Netlify deploys, Formspree with a real box, Google Places with a billed key, real calls and sends.


v5 — independent QA pass, 2026-08-03

An independent reviewer ran the kit rather than reading it, and the scraper's core held up: every number it printed matched the file it described, on three live cities. What did not hold up was a layer of confident sentences on top, plus four ways the tool could quietly destroy the buyer's work. Everything below was reproduced, fixed, and then re-run.

Data loss — the four that would have cost a refund

Was Now
Rerunning the same command replaced leads.csv and lookup-list.md. Every phone number the CEO had typed in was gone — while scraper/README.md and CLAUDE.md both told them rerunning "merges". A run reads the existing CSV, merges into it (existing rows win, blanks fill from the new ones), harvests numbers typed onto the lookup list's Phone found: lines, and copies the old file to <name>.csv.bak. It prints what it merged. --fresh is the opt-out. The docs were right and the code is now what they described.
A rerun with no hot leads deleted lookup-list.md outright (os.remove). Never deleted. If nothing hot is left, the file is left exactly as it is and the run says so.
--source google with a rejected key called sys.exit after the keyless OSM half had finished — a completed five-minute scrape, no CSV written. Prints the same diagnostic block and returns empty. Verified on Coeur d'Alene: bad key, HTTP 400: API key not valid, and the run still wrote all 26 OSM leads and exited 0.
--out no-such-dir/leads.csv ended a finished scrape in a raw traceback, leads lost. The output folder is created (or the problem explained in plain English) before the first query, and the write itself has a friendly error for "the file is open in Excel".

Wrong output

  • extract_contacts took the first phone-shaped string on the page — and "License #ROC 123-456-7890" is phone-shaped and sits above the phone number on a great many plumbing sites. That licence number became the lead's phone, counted as DIALABLE, and pushed the lead off the lookup list. Now: tel: links win; otherwise the nearest label decides, and licence/registration/tax wording disqualifies a candidate. The regex also now matches (480)555-1234 and 4805551234, which it previously missed.
  • New phone_source column (appended, per the column contract) says where each number came from: map data, your hand-built list, you looked it up, or their website (...). Documented in scraper/README.md, prompts/01-prep-calls.md and prompts/02-research-lead.md.
  • dedupe() collapsed distinct businesses that shared a name and had no phone and no address — three real Roto-Rooter branches became one row. It now uses the same identity ladder as the merge engine (OSM ID → phone → name AND address) and never collapses on a bare name. Merge rule 3 also learned rule 4's distance check, so two same-name branches twelve miles apart stay two rows, exactly as this file has always claimed.
  • --no-enrich left weak_web_score blank for every lead that HAS a website, which sorted as 0 and put supply houses above dialable contractors. Blank now sorts between the measured rows and the 85/90 rows, the reason column says why it is blank, and the flag's own help text and README row say what the trade-off costs. Supply-house rows also now sort to the bottom of the file whatever they score — a wholesaler with no website scores 90 and is not a phone call, and this file is supposed to be "sorted so your best calls are at the top". Verified on the reviewer's own Pueblo case: the two dialable contractors are now rows 1 and 2, the two wholesalers last.
  • The score cap was 100, the docs said 0–90. A measured page now stops at 80, so 85 ("listed site didn't load") and 90 ("no site in the map data") keep their reserved meanings. README, its score table and CLAUDE.md agree.
  • Spreadsheet-formula injection. leads.csv is written with =, +, - and @ prefixes neutralised — the kit tells the buyer to open this file in a spreadsheet, and OSM names are volunteer-edited free text. Phone numbers are exempt so +1 719-... stays readable. count_dialable also now requires seven digits, so a misaligned hand-list column can't inflate the headline.
  • Printed commands dropped cities. A two-city run printed follow-up commands naming only the first city — including the one town that had produced everything. Every suggested command is now rebuilt from the full city list, the county hint is derived from the CEO's own state instead of suggesting "Maricopa County, Arizona" to somebody in Louisiana, and --niche pet_groomer no longer prints plumber advice.

The two that would have embarrassed us in public

  • The quoted Mesa transcript never happened. docs/what-you-will-see.md opens with "real output from real runs, copied unedited" and then printed 43 + 66 + 55 rows producing 107. Those three numbers must sum to the row count. Investigating it found a real bug: the metro widener relabelled rows after merging, so rows that had been kept apart by their city label collapsed later and the arithmetic silently stopped adding up. Fixed (label first, then merge; and the city batch is self-merged before it is counted), and both files now carry a verbatim run from 2026-08-03: 43 + 73 + 4 = 120 rows, 17 dialable, busy-server retries included.
  • The correction that contained a wrong number. The "Doc-vs-code number drift" section cited leads.py:344 for [timeout:120]; line 344 was a closing bracket. Both citations now name the functions (build_query, build_radius_query), which survive the next edit.

Integrity and consistency

  • Both EXAMPLE builds published a promise broader than the pretend client's written answer. Their READMEs said claim 7 was kept as "1-year workmanship warranty", and both pages still had a Guaranteed Work panel promising an unbounded return. Fixed on the pages, not in the READMEs: the word "guaranteed" now appears nowhere in either EXAMPLE build.
  • fulfillment/CLAIMS.md contradicted itself on its own pass condition — "all three must come back empty" above the greps, "grep 3 will print lines — that's the point" below them. Now consistent in all three places, so the one blocking gate in the kit can't read as a failure to the buyer it was written for.
  • "It can pay for itself" is now banned in both directions, matching the rule the pet-groomer kit already ships. The unsourced "$300+ job" is gone from playbook/growth.md, the payback line is gone from the client-facing textback one-pager, and guardrail 6 plus prompts/06-upsell.md say plainly that hedging a made-up number to "can" does not fix it.
  • The care-plan milestone paragraph no longer sits under the tier table doing the reader's multiplication, and plumber growth now carries the same "we can't quote you a churn number" disclosure the other kits have.
  • Unsourced competitor pricing deleted from office/proposal-onepager.html (a document handed to a stranger) and from prompts/04-invoice.md. We have never surveyed agency pricing; the price is now justified by how the work is done.
  • office/data-handling-addendum.md + .html added and made a hard gate. The review engine moves a client's customers onto the CEO's laptop every month; this kit was the only one of the three with no paper for that. Gated in CLAUDE.md guardrail 8, care-plan-sop.md step 3, review-engine/ask-flow.md step 0 and the module README.
  • fulfillment/publish-a-demo.md added. Half the outreach templates promise a free mockup; the kit then stopped at an index.html on the CEO's laptop. A demo is a link. prompts/03-fulfill.md mockup mode now produces the publishing steps and names the claims it deleted.
  • Week-one dial counts reconciled inside the kit. CLAUDE.md still said "first 25 calls" and "25 leads a day" while START_HERE.md and playbook/week-1.md had been fixed to the 10–15 ramp. CLAUDE.md now runs on the ramp in docs/your-phone-setup.md.

Also fixed in the two other Python files

  • agency-site/fill.py and variant-b/fill.py did no HTML escaping. An agency called "Smith & Sons Digital" corrupted ten injection points in index.html — the browser opened a tag that never closed. Values are now html.escape(..., quote=True) for .html and JSON-quoted for .js. Verified by building both designs with agency_name: Smith & Sons Digital <Web> and owner_name: José O'Brien and parsing the output: zero unclosed tags.
  • fill.py now says it is overwriting dist/ rather than silently replacing hand edits, and points at the source files instead.
  • make_qr.py refuses to overwrite an existing qr.png unless --force is passed. Client #2's code silently repointing client #1's already-printed cards is the one failure the kit's own "always scan the printed card" rule cannot catch.

Verified by re-running, not by reading

Mesa (full run, 120 rows), Coeur d'Alene with a deliberately bad Google key, Cañon City + Pueblo --no-enrich (multi-city ranking and printed commands), hand-list merges with typed-in numbers on both the CSV and the lookup list, --out into a missing folder and into a read-only folder, --fresh, both fill.py scripts, and make_qr.py twice in a row.

Still not tested live (unchanged)

Netlify deploys, Formspree with a real box, Google Places with a billed key (the rejected-key path IS now tested), real calls and sends.


v6 — 2026-08-04. A cold Claude ran the shipped zip, and it found three things that would have hurt a real buyer.

Read this section before you trust any "PASS" above it. Everything above was written by people who built the kit. This section exists because we handed the delivered zip to a Claude with no other context and told it to run the business for a non-technical owner. It could — it configured the kit, scraped a real city, rescued a dead result into 11 dialable leads, built a client site and answered the owner's questions from the box alone, in eighteen minutes. It also found three defects that had survived two previous repair passes, and it found them by following the documentation exactly, which is the only way these were ever going to be found.

The three that would have hurt a buyer

1. The zip shipped with another city's leads already in scraper/leads.csv. 131 rows from Tempe, Arizona. Rerunning is additive by design (that is a deliberate feature that protects numbers you typed in), so the tester's very first Sioux Falls run inherited all of them and the headline box printed DIALABLE NOW: 19. All nineteen were 1,200 miles away. CLAUDE.md calls that box "the only number to report to the CEO", so following our own instructions literally would have told a South Dakota buyer they had a week of local calls.

Fixed three ways. The file no longer ships. verify-this-kit.py now fails if it — or a dist/, or an office/clients/, or a filled-in business.yaml, or a stray call list — is present in a fresh copy, and that script is the packaging gate. And the headline box itself now splits the count by city and shouts when rows in the file are not in the city you searched. Verified by seeding five Tempe rows into a file and running Sioux Falls into it:

      DIALABLE NOW: 5
        in Sioux Falls, South Dakota: 0
        somewhere else:      5
      ===============================================
      !! 5 of those 5 are NOT in the city you searched. They were
         already in contaminated.csv from an earlier run, in: Tempe, Arizona.
         Do not report them to anyone as local leads.

2. The delivery checklist's own copy command published a different plumbing company on the paying client's live domain. It said cp -r fulfillment/website-template office/clients/<slug>/site, which brings along the template's README.md and its entire EXAMPLE/ folder — a complete, finished website for the made-up company Ironwood Plumbing Co. of Mesa, Arizona. Two steps later the checklist says to drag that folder onto Netlify. The tester avoided it only by disobeying the documented command.

Fixed. Every copy instruction in the kit — delivery-checklist.md, CLAUDE.md, prompts/03-fulfill.md — now copies the three site files by name and says plainly why. And fulfillment/check-build.py fails the build if anything other than those three files (plus images) is in the folder, or if the demo business's name, phone or email appears anywhere in it. Verified by running the old cp -r on purpose:

FAIL  Extra files in the site folder
        office/clients/acme/site/EXAMPLE
        office/clients/acme/site/README.md
FAIL  Demo content in the build: 'Ironwood Plumbing'
        EXAMPLE/index.html:14: <title>Ironwood Plumbing Co. — Plumber in Mesa, AZ …

3. The blocking pre-launch check could never pass. styles.css and script.js each carried a {{BUSINESS_NAME}} in a header comment, in both templates, documented in no token table. So grep -rn "{{" . — the gate that CLAUDE.md and CLAIMS.md both call blocking — failed on every correctly finished build, forever, for a reason that had nothing to do with claims. That is the worst possible failure mode for a safety gate: it teaches the operator that gate failures are noise, and it is the same gate protecting the claims check, the one check in this kit that protects somebody other than the buyer. A note left in the zip by an earlier tester had already documented this exact defect. The fix never reached the template.

Fixed, and made harder to regress. Those two comment lines are now static text. verify-this-kit.py fails if a placeholder ever reappears in a CSS or JS file. And the gate is no longer four greps a buyer has to remember — it is one command, fulfillment/check-build.py, which also catches things no grep did: build-note comments left in the client's page source, dead tel: links, and an invented Formspree ID.

Verified by building a client site from each template the documented way, filling it as an operator would, and running the gate:

$ python3 fulfillment/check-build.py office/clients/riverbend/site
Mechanical checks passed. Now the half only you can do:
Every line below is a promise now live under your client's name.
        index.html:41: <p class="hero-kicker">Licensed &amp; insured • Sioux Falls, SD</p>
        index.html:52: <li>✓ Upfront, flat-rate pricing</li>
        ...

Exit 0 on both design A and design B. The gate is passable now.

The strategic gap it found, which no packaging fix touches

Every plumber in the tester's city already had a good website. Eleven of eleven scored 0 — "site looks OK" — and the two it opened by hand had tap-to-call, contact forms, live reviews and a 2026 copyright. The kit's entire sales motion was "your website is weak", so all three openers died: A had no weak point to name, B was false for everyone, and objection 5's dare ("pull it up on your own phone right now") gets disproved on speaker in four seconds. A second test, in a different city, reached the same conclusion. Both were outside the Sun Belt. This kit was honest that map COVERAGE varies by town, and never considered that the MARKET might already be served.

That is now a written part of the product, not a gap the operator has to improvise around:

  • sales/call-script.md has a "When the whole town already has a decent website" section: an opener that compliments the site honestly and pivots to the missed-call question, which is true of every plumber regardless of their site.
  • sales/objections.md #5 is split in two, with a concede-immediately branch for a healthy site.
  • CLAUDE.md carries an explicit, conditioned exception to the "upsells are never pitched cold" rule, so missed-call textback can be the lead offer in a score-0 market — which is what the tester had to invent, and then flag in writing as going off-script.
  • scraper/README.md's score table no longer says "lead with upsells" for a score-0 lead, which pointed at an offer the kit's own guardrail forbade.

What we changed because the tester had to guess

Every guess is a hole in "hand it to your Claude", because a weaker model guesses wrong and the buyer never learns why their business stalled.

  • What weak_web_score: 0 means. The tester learned it by reading the scoring function. The exact seven checks are now printed in scraper/README.md, prompts/01-prep-calls.md and sales/call-script.md, with the warning that essentially every site built after ~2015 clears the bar.
  • Which weak points may be spoken aloud. The scripts modelled "no tap-to-call" and "takes forever to load" as the flagship openers; the scraper measured neither. Tap-to-call is now measured — the scorer looks for a tel: link, +20 — so the kit's best opener finally has evidence behind it. Page speed is not measured and is now banned by name in the call script, the objections, the SMS and email templates, and CLAUDE.md.
  • Version C's closing line. It ended "I'd bet money it's not set up to catch calls on a phone" — an assertion about a site nobody looked at, in the opener defined by asserting nothing, false for at least 4 of the tester's 11 leads. Cut from the script and from office/EXAMPLE-call-list.md, where a weaker model would have learned it was acceptable.
  • What counts as "a national chain". Prompt 01 said to drop them and never defined it, so the tester cut Roto-Rooter and Mr. Rooter on a guess — both locally-owned franchises with a local owner who could buy. The scraper now flags them as a national brand lead type, KEEPS them as dialable, ranks them below independents, and prompt 01 explains the real reason they are a harder call (the franchisor controls the website).
  • Client-size fit. Nothing said how big is too big, so the tester cut a 9-name list to 4 on its own judgement. Prompt 01 now lists the size signals and requires them to be flagged, not deleted.
  • Reviews at mockup time. The token gate demanded three reviews and a rating, which on a mockup forces an operator to invent them or know to delete the section. Both templates now carry a build note at the reviews section saying exactly what to do when there are no real reviews yet.
  • The guarantee panel. "We come back and make it right. Simple as that." is open-ended, so a yes to the intake question still leaves the operator writing material terms by hand — the tester invented the word "labour". The claim fence now says to write the client's actual window and scope, and never to invent either.
  • The leftover build comment telling the reader that claims were deleted from the page. Every operator-facing comment in both templates is now tagged BUILD-NOTE, and the gate fails on any comment that is not a plain section divider.
  • Whether the Formspree ID is real. The tester invented xldgqrbo because the gate demanded no {{ remain. The gate now POSTs to the endpoint: a 404 proves the ID is fake and fails the build. What that check does NOT prove is that a real endpoint works — we have no Formspree account and have never seen what a live one answers, so anything other than 404 is reported as "not disproved" and the live test-lead step stays mandatory. Verified both ways: an invented ID and the README's own example ID both now fail the gate.

Two bugs we found by re-running our own fixes

Neither was in the tester's report. Both were found because every change in this pass was executed rather than reasoned about.

  • weak_web_score: 0 was being ranked as "not checked". The rank function read str(value or ""), and 0 or "" is "" in Python — so a measured, perfectly-fine site got rank 82 and sorted above a measured, genuinely awful site at 80. It only bit within a single run (a reread from CSV gets the string "0"), which is why nobody caught it: the same lead ranked differently on run one and run two.
  • --merge-csv crashed the advice block. The new "you already merged a list, add more names to it instead" branch called os.path.basename on args.merge_csv, which is a list because the flag is repeatable. It raised a TypeError after the CSV had been written — so the buyer would have seen a Python traceback at the end of a successful rescue run, which for this audience is indistinguishable from "it broke".

Also fixed in this pass

  • Both templates: the H1 "The plumber your neighbors already call" (a social-proof claim about the client, in no CLAIMS.md entry), seven hardcoded ★★★★★ rows sitting above review text pulled from the client's real Google reviews (a 3-star review rendered as 5 stars), "Request a Free Estimate" on four buttons (many plumbers charge a trip or diagnostic fee), and "we leave it cleaner than we found it" (a promise about how somebody else's employees behave in a stranger's house). CLAIMS.md is now 13 claims, A has 8 CLAIM-BLOCKs and B has 11, and the intake form collects the two new ones.
  • playbook/week-1.md: the free --merge-csv rescue path now appears before the one that needs a credit card. It was missing from the playbook entirely while CLAUDE.md required the opposite order.
  • Both playbooks: "dialable leads in the file" is no longer listed under "effort — you control this completely". It is a property of your market, the arithmetic (3 touches per lead, so 300 dials needs ~100 leads) is now printed, and the measured yields from our cold runs sit beside it.
  • agency-site/ (all four builds): "a homeowner compares three plumbers in ninety seconds" (invented specificity that reads as a statistic), "Questions plumbers actually ask us" (you have zero clients on day one), "a small local team" (you are one person), "a preview link in your hands 2–4 days after that call" (a delivery promise nobody has ever tested), and "reviews compound" (an asserted mechanism).
  • docs/rules-of-the-road.md contradicted itself on state telemarketer registration — "sometimes with a bond" and then "usually a form and a modest fee, not a barrier", neither checked. The reassurance is gone.
  • The three office/EXAMPLE-*.html documents now carry a visible disclosure banner in the rendered page, which START_HERE.md had claimed of every example page for two versions while it was false for those three.
  • The scraper's output is no longer block-buffered (python3 leads.py > log.txt produced a log containing only the OpenSSL warning for the whole run, and any reader concluded it had crashed), and every phase line prints elapsed seconds so a slow run is distinguishable from a hung one.
  • Two branch listings of one business sharing a website are now collapsed into one row carrying both numbers, instead of inflating DIALABLE NOW with a lead you would ring twice.

Measured this pass

  • Sioux Falls, South Dakota, keyless: 1 row, 0 dialable, 139 seconds. The full unedited transcript is in docs/what-you-will-see.md section 5.
  • Hand-list merge into Sioux Falls: rows with a website filled in were fetched and scored (30 — no tap-to-call, nearly empty homepage) and sorted above the unverified rows. That is the ranking fix working.
  • Client site built from template A and from template B, filled, gated: exit 0 on both.
  • Both agency-site designs built with fill.py and read end to end: zero assertions about experience a day-one buyer does not have.

Still not tested live (unchanged, and we are not going to pretend otherwise)

Netlify deploys, Formspree with a real inbox, Google Places with a billed key, and any actual phone call. Nobody has sold anything with this kit.

📄 scraper/README.md

Open file

Lead Scraper — build your call list

This is your lead machine. One command pulls every plumbing business OpenStreetMap knows about in your city (or several cities at once), widens automatically to the surrounding metro area when your city is short on leads you can actually phone, checks their websites, and ranks them by how badly they need what you sell.

No API key, no account, no card — including the rescue path. The default source is free map data. If your area turns out to be thinly mapped, the fix we recommend first (--merge-csv, below) is also free and cardless. The optional Google Places source is faster but needs billing enabled on a Google Cloud project; you never have to touch it.

Where this kit is strong, and where it's thin

Measured runs, not estimates — the full logs are in ../docs/FIELD_TEST.md:

Area One keyless command gave us Verdict
Mesa, Arizona 120 rows / 17 dialable (2026-08-03). Three runs across two days gave 107/17, 114/15 and 120/17 — same command, same city; the map and the free servers both move Strong. Most of a morning of calls from one command
Phoenix + Gilbert, Arizona (one run) 150 rows / 25 dialable Strong. Two towns merged, no duplicates
Chattanooga, Tennessee 2 rows / 1 dialable Thin. Map desert — go to "When your city runs thin"
Toledo, Ohio 3 rows / 1 dialable Thin. Map desert — same

The pattern we can see in our own runs: newer, fast-growing Sun Belt metros are mapped well; older mid-size cities can be nearly blank. We have not run all fifty states, so treat that as a pattern, not a rule — the only way to know about your town is to run the command, which takes two minutes and costs nothing. A thin result says something about volunteer map coverage in your area. It says nothing about how many plumbers work there, and nothing about you.

Read this first — the honest version of what day one looks like

Free map data is wonderful and uneven. Some cities hand you two hundred plumbers. Some hand you one. That is not a bug in this script, it is the world: OpenStreetMap is drawn by volunteers, and some places have an active mapping community and some don't.

So here is the real shape of day one, and the kit is built around it:

Day 1 is building the list. Day 2 is calling it.

A single scrape of a single city is a start, not a finished call list. What you're aiming for is about 40 leads with phone numbers — roughly a week of calling at 25 dials a day once you allow for no-answers and wrong numbers. Some CEOs get there in one command in ten minutes. Some need to add three neighbouring towns and spend half an hour on the phone-lookup list. Both are normal and both end in the same place.

The number to watch is DIALABLE NOW, which the script prints in a box at the end. It counts rows that have a phone number and look like an actual contractor. Row count is not a call list. Ignore it.

Before your first run — you need Python

The scraper is a Python program. If you haven't installed Python yet, do ../docs/connect-claude.md, Step 2.5 first — it's five minutes and it is the single most common place people get stuck.

Windows: everywhere this file says python3, you type py -3 instead. So py -3 leads.py --city "Mesa, Arizona". If you type python3 on Windows you'll get the Microsoft Store opening at you, which is not Python.

Run it

cd scraper
python3 -m pip install -r requirements.txt   # first time only
python3 leads.py --city "Mesa, Arizona"

That's it. You get leads.csv in this folder, sorted hottest-first — plus lookup-list.md if any hot leads are missing a phone number (see below).

Always include the state in --city. "Mesa" alone could match five places on Earth. "Mesa, Arizona" matches one.

Or just ask your Claude: "run the lead scraper for my city and tell me how many dialable leads I got." It reads your city out of config/business.yaml, runs the command, reads the verdict, and tells you what to do next. That's the intended path — the commands are here so nothing is a black box.

Options

Flag What it does Default
--city "City, State" Where to scrape. Required unless you're only using --merge-csv. Repeat it to merge cities: --city "A" --city "B"
--niche plumber Business type to hunt plumber
--source osm Lead sources: osm (free, keyless) or google = OSM + Google Places merged (OPTIONAL, needs an API key — see below) osm
--merge-csv FILE Fold in a list you built by hand (free, no key, no card). Only a name column is required. Repeat for several files; works with or without --city
--api-key KEY Google Places API key (only with --source google; the GOOGLE_PLACES_API_KEY env var works too)
--out myfile.csv Where to save results. If that file already exists, this run merges into it — rows you edited are kept, new leads are added, nobody is counted twice, and the old file is copied to myfile.csv.bak first. Folders that don't exist yet get created leads.csv
--fresh Start the output file over instead of merging into it (the previous file is still copied to .bak first) off
--no-enrich Skip visiting lead websites (faster run). Trade-off: any lead that HAS a website gets no weak_web_score at all — the column is left blank, not 0 — and no phones or emails are pulled off pages. Blank rows sort between the measured ones and the 85/90 rows, so the "hottest first" order is rougher than a normal run off
--timeout 12 Seconds to wait per website check 12
--radius-miles 15 How far around your city centre to widen when the city itself runs thin 15
--widen-below 40 Widen to the metro area when the city's own boundary returns fewer than this many dialable leads (leads with a phone number — not rows) 40
--no-widen Never leave the city limits, even on a thin result off

Examples:

python3 leads.py --city "Tulsa, Oklahoma"
python3 leads.py --city "Mesa, Arizona" --city "Gilbert, Arizona"   # two cities, one list
python3 leads.py --city "Boise, Idaho" --out boise-leads.csv
python3 leads.py --city "Gilbert, Arizona" --no-enrich   # quick pull, no site checks
python3 leads.py --city "Toledo, Ohio" --merge-csv my-list.csv      # + your hand-built rows

The engine also knows --niche pet_groomer — same machine, different prey — but this kit is built around plumbers, so leave the default alone unless you're experimenting.

When your city runs thin — four fixes, in order

Gate on dialable leads, not rows. If the run gives you fewer than about 40 leads with phone numbers, you don't have a week of calling yet. Work down this list until you do.

Fix 1 (automatic) — the metro widener

City boundaries are political lines. Half the plumbers who serve your city are mapped in the suburb next door, ten minutes up the road, and perfectly callable.

So when a city's own boundary comes back with fewer than 40 dialable leads, the scraper automatically searches about 15 miles around the city centre and merges the results. Note what triggers it: dialable leads, not rows. A city can hand back 43 businesses with 4 phone numbers between them — that's a thin city wearing a fat row count, and you can't ring a row.

You don't do anything. You just see this:

      Got 43 unique named businesses inside the city limits (4 of them with a phone number — that's the number that matters).
      Only 4 of those are dialable, so widening to ~15 miles around the centre. This is the slow part of the run — give it a minute or two.
      Overpass is busy — waiting 8s, then retrying (attempt 2/3)...
      Overpass is busy — waiting 20s, then retrying (attempt 3/3)...
      +73 more from the surrounding area (marked "Mesa, Arizona area" in the city column) — 15 dialable now.
      Still short — one last, slower sweep that also matches businesses by name...
      Overpass is busy — waiting 8s, then retrying (attempt 2/3)...
      +4 more from the surrounding area (marked "Mesa, Arizona area" in the city column) — 16 dialable now.

That is a real Mesa run, pasted from the terminal on 2026-08-03: 43 rows / 4 dialable inside the city limits became 120 rows / 17 dialable once the metro was included. Roughly a 4x difference in actual phone calls, from a step you didn't have to know about. The +N numbers always add up to the row count printed at the end — 43 + 73 + 4 = 120 — so you can check the run's own arithmetic any time you want to.

Those extra leads get city values ending in "area". That matters on the phone: you say "I'm local here in the Tampa area", not "here in Tampa", to a shop that's actually in Brandon. Your Claude knows this rule.

Turn it off with --no-widen, or change the distance with --radius-miles 35.

(Nerd footnote, ignore freely: the widener searches a square around your city rather than a circle. Free map servers answer a square question in seconds and routinely give up on the circle version. Same leads, minutes faster, far more likely to work at all.)

Fix 2 — name the neighbouring towns yourself

Pass --city more than once and you get ONE leads.csv, merged and deduped:

python3 leads.py --city "Mesa, Arizona" --city "Gilbert, Arizona" --city "Chandler, Arizona"
  • Each lead keeps its own city value, so you always know where it came from.
  • You will never be handed the same plumber twice. Neighbouring towns overlap a lot once the metro widener has run, so the scraper checks four things before adding a lead: the map's own ID for that business, the phone number, the name within a city, and the name within a couple of miles whatever the city label says. Any match and the two records become one row with the blanks filled in from both.
  • Genuine separate branches — same name, different side of the metro, or a different phone number — stay as separate rows, because they're separate phone calls.
  • Field-tested, 2026-08-03: --city "Phoenix, Arizona" --city "Gilbert, Arizona"150 unique leads, 25 dialable, in one file, from two towns whose metro searches overlap almost completely. Four same-name pairs survived the merge and all four are real separate branches five to twenty miles apart.
  • Don't know your neighbours? Ask your Claude: "what are the 5 nearest towns to [my city] with at least 20,000 people?" — then rerun with all of them.

Also worth a try: scrape the whole county, --city "Maricopa County, Arizona".

Fix 3 — build 20 by hand and merge them in (FREE, no key, no card)

If you're still short after fixes 1 and 2, your area is an OpenStreetMap coverage desert and no amount of rerunning will change that. Some genuinely mid-size cities have one plumber mapped county-wide. That's the data, not you.

This is the fix that always works, in every city, without giving anyone your card. You do the finding; the scraper does everything else.

  1. Copy the template: cp hand-list-template.csv my-list.csv (Windows: copy hand-list-template.csv my-list.csv.) Open it in a spreadsheet — it's one header row: name,phone,website,city,address,email.
  2. Search plumber near [your city] in Google Maps. Work down the results and type in the name and the phone number for each one. Website, city and address if it's there; skip them if it isn't. Only name is required. Twenty rows is a boring hour, and it is the single highest-value hour on a thin day one.
  3. Save as CSV, then merge it into a normal run:
python3 leads.py --city "Toledo, Ohio" --merge-csv my-list.csv

What happens to your rows: they're de-duplicated against the map data (a hand row and a mapped row for the same business become one row with the blanks filled from both), their websites are fetched and scored like everything else, they get a lead_type, and they land in the same ranked leads.csv. The source column shows hand, or osm+hand where the two agreed.

You can also run the hand list on its own, with no city at all:

python3 leads.py --merge-csv my-list.csv

Field-tested 2026-08-03, Chattanooga (a map desert): the keyless run gave 2 rows / 1 dialable; the same run with a three-row hand list merged in gave 4 rows / 4 dialable, and one hand row filled in the missing phone number on a mapped lead instead of duplicating it.

Ask your Claude to do the searching: "Search Google Maps for plumbers in [my city] and fill in scraper/my-list.csv from the template — name and phone for each, no invented numbers." Then check a couple by hand before you dial: never dial a number nobody verified.

Fix 4 — turn on Google Places (faster, needs a card)

Google Places knows essentially every business in America, and its rows almost always arrive with phone numbers. It takes about ten minutes to set up, requires a Google Cloud project with billing enabled (a card on file), and there is a free monthly usage allowance. Full walkthrough below under "OPTIONAL: add Google Places as a second source" — including the honest note that we cannot field-test the live-key path, which is exactly why fix 3 comes first.

This is a normal, expected step for a thin city — not an admission of defeat. But it is a choice, not the only road: fix 3 gets you to the same place for an hour of your time instead of a card number.

No phone on a hot lead? lookup-list.md

Map data very often has the business but not the number. So the scraper writes lookup-list.md next to your CSV (with --out boise-leads.csv it's named boise-leads-lookup-list.md): every weak-web-score-85+ lead with no phone, each with a pre-built Google Maps search link. Click the link, read the number off the Maps listing, paste it in.

Budget about a minute each, honestly. Twenty of them is a solid half-hour of clicking. It's the least glamorous half-hour in the kit and it's the one that turns a spreadsheet into a morning of phone calls. Or paste the file's built-in prompt to your Claude and let it do the lookups with web search.

Your typing is safe. Numbers you write on the Phone found: lines are read back the next time you run the scraper: they land in the phone column (marked you looked it up in phone_source) and those leads drop off the list because they have a number now. And if a later run finds nothing hot left to look up, the old file is left exactly where it is rather than tidied away. Half an hour of your morning is not something a rerun gets to delete.

Two rules, both in the file:

  • Never dial an unverified number. If no listing turns up, the business may be gone — skip it, don't guess.
  • While you're on the Maps listing, look at the "Website" button. If they have one, note it. The map data was wrong, and you must not open the call with "I couldn't find you online."

What the columns mean

leads.csv, sorted so your best calls are at the top: highest weak-web score first, and anything flagged as a supply house pushed to the bottom of the file whatever it scores (a wholesaler with no website scores 90 and is still not a phone call). Flagged rows are kept, never deleted — an unlucky family name can flag a real contractor, and the lead_type column is right there so you can check.

Column Meaning
name Business name.
phone Phone number — from the map data, pulled off their website if the map didn't have it, or typed in by you. Which of those it was is in phone_source.
email Email, same two sources. Often blank; the phone is your weapon anyway.
website The site the map data knows about. Blank means the map doesn't have one — see the big warning below.
address Street address when mapped. Sometimes partial — OSM data varies.
city Which city pull the lead came from. A value ending in "area" means it came from the metro widener — it's near your city, not necessarily inside it.
weak_web_score The money column. 0–90. How weak their web presence is = how much they need you. See below. Blank is not zero — it means nobody looked (you ran --no-enrich).
weak_web_reasons Plain-English why: "no SSL", "not mobile-friendly", "copyright frozen at 2014"... Use these lines ON the call — after you've checked they're true.
category How the source tags them (craft=plumber from OSM, plumber from Google, etc.).
opening_hours When they're open, if known — call when they're in.
lat, lon Map coordinates. Paste into Google Maps to see the shop.
osm_type, osm_id OpenStreetMap references, for your Claude's deeper research. Blank on Google-only rows.
source Where the lead came from: osm, google, hand (you typed it in via --merge-csv), or a combination like osm+google / osm+hand — found by more than one, which is a good "this business is real" signal.
lead_type contractor = your customer. supply house? — verify before calling = probably a wholesaler, distributor or hardware store that got caught by the map search; not your customer, your Claude drops these at call prep. national brand — website is usually corporate's, verify = Roto-Rooter, Mr. Rooter, Benjamin Franklin and friends. These are kept and they still count as dialable — nearly all of them are locally owned franchises with a local owner who can buy. What makes them a worse call is that the website is normally built and controlled by the franchisor, so a "your site is weak" pitch aims at a marketing department the owner cannot overrule. They sort below the independents and your Claude tells you which ones they are. Nobody deletes them on a hunch: "drop anything obviously a national chain" used to be the instruction, nobody had defined it, and real prospects got cut from short lists on a guess.
phone_source Where the NUMBER came from: map data · your hand-built list · you looked it up · their website (tap-to-call link) · their website (text on the page — check it before dialling). The last one is the only one worth a second glance: a plumber's homepage prints its licence number in the same shape as a phone number, and while the scraper refuses any number sitting next to licence wording, thirty seconds of eyeballing beats dialling a stranger's licence number.

🚨 The most important paragraph in this file

A blank website column does NOT mean the business has no website.

It means OpenStreetMap doesn't have one on file. Those are completely different statements. Volunteers map a shop's location and forget the website field all the time. In real test runs, businesses with perfectly good live websites showed up with a blank column — including some very large companies.

So the score for a blank website field is 90, not 100, and the reason text reads "no website found in map data — VERIFY by searching their name before you dial." That wording is deliberate and it is not padding.

Never open a call with "I looked you up and couldn't find you" unless you personally looked them up. Say that to a business with a perfectly good website and the call is over in four seconds — you've just told a stranger you did no homework, in the exact sentence where you claimed you did. It is the most humiliating possible way to fail, and it would happen on call #1.

The verification takes ten seconds: search their name plus your city. Your Claude does it automatically in prompts/02-research-lead.md, and prompts/01-prep-calls.md refuses to write a "no website" opener for a lead nobody checked.

The weak-web score

Score Meaning Your move
35–80 Site exists and has real, measured problems. The weak_web_reasons column is your pitch, word for word. These were measured on their real page, so you can say them out loud with confidence. These sort to the TOP of the file.
1–30 Site exists and has one smaller measured problem. Same rules, weaker hook. Usable.
90 No website found in the map data. NOT measured. Search their name first — ten seconds. If they really have nothing, this is your best call of the day. If they do have a site (common), look at it and pitch what's actually wrong instead.
85 Website listed but dead / unreachable the one time we fetched it. Try it in your own browser before you say anything. One failed fetch is not proof a site is down.
(blank) Nobody looked — you ran --no-enrich and this lead has a website that was never visited. Not a ranking, an admission. Rerun without --no-enrich to score them.
0 Measured, and nothing is wrong with it. There is no weak point and you may not invent one. See below.

Read the order of that table carefully — it is the order the file is sorted in, and it changed. Measured rows come first; 90, 85 and blank are grouped below them; score-0 rows are last. Earlier versions sorted on the raw number, so the least-verified leads in the file wore the highest heat score and landed in your first ten calls. In a cold field test, every one of the top-ranked 90s turned out to have a perfectly good website — the ranking was 8-for-8 wrong at the top of the file while the three genuinely measured leads sat at the bottom.

Exactly what gets measured (nothing else does, and nothing else may be said on a call):

Points What was measured on their homepage
+35 served over plain http, no SSL
+25 no viewport meta tag — renders as a shrunken desktop page on a phone
+20 no tel: link anywhere — nothing to tap to call
+15 ancient markup: <frameset>, <marquee>, <font>, bgcolor=
+10 a copyright year of 2021 or earlier in the footer
+10 homepage under 3,000 characters
+5 missing or empty <title>

Page load speed is not measured. Whether they show their reviews is not measured. If you say either of those on a call, you are guessing, and the prospect can disprove you in four seconds by opening his own phone.

0 / "site looks OK" is the trap in this table. It does not mean the site is good. It means it clears the seven-point bar above — SSL, viewport, a tap-to-call link, not-1998 markup, a recentish year, some content, a title. Almost every site built after about 2015 clears that bar. So a 0 means only one thing: this kit found nothing you are allowed to say about their website.

That is a real situation and it has a real answer. Do NOT fall back to "lead with upsells" as though the score table were a pricing menu — the kit's own rule is that upsells are pitched after a happy delivery, never cold. Instead go to "When the whole town already has a decent website" in sales/call-script.md. It gives you an opener that compliments the site honestly and pivots to the one question that is true of every plumber alive (what happens to the call when you're under a sink), and it gives you written permission to sell missed-call textback as your lead offer in that market.

If most of your file is 0, that is not a broken scrape. It is information: your market is well served on websites, and the website pitch is the wrong pitch there. In one cold field test, 11 of 11 dialable leads in the city scored 0.

Why 80 is the ceiling for a page we actually fetched. 85 and 90 are reserved: they mean "we have no data on this site", not "we measured it and it was terrible". A real page that fails every single check stops at 80, so a score of 85 or 90 always means exactly what the two rows above say it means.

OPTIONAL: add Google Places as a second source

Day one never needs this. The keyless OpenStreetMap path is the default and it's what we field-test on.

If your keyless run came back thin even after adding neighbouring cities, this is the fastest fix — but it is not the only one. Do fix 3 (a hand-built list merged in with --merge-csv) if you'd rather not put a card on a Google Cloud project; it costs you an hour and nothing else, and it is the path we can run end-to-end ourselves. Google Places knows essentially every business in America and its rows almost always carry phone numbers — the exact thing OSM is weakest on. Ten minutes, once.

It DOES require a card on file and an API key. Google publishes a monthly free usage allowance for Places, and a handful of scraper runs is a small amount of usage — but the shape of that allowance has changed before and will change again, so check Google's current pricing page before you enable billing rather than trusting any number written in a document. Set a budget alert on the project while you're in there; it takes one minute and it means you can never be surprised.

Setup, once, ~10 minutes:

  1. Go to https://console.cloud.google.com/ and create a project (any name).
  2. Enable billing on the project (card required — the free monthly allowance applies automatically). While you're there: Billing → Budgets & alerts → create a budget of $5 with an email alert. Now you cannot be surprised.
  3. In "APIs & Services", enable Places API (New) — note the "(New)": it's a separate switch from the old Places API, and this scraper uses the new one.
  4. In "Credentials", create an API key. Optionally restrict it to Places API (New) — good hygiene.
  5. Run:
python3 leads.py --city "Chattanooga, Tennessee" --source google --api-key YOUR_KEY
# or, so you never paste the key again:
export GOOGLE_PLACES_API_KEY=YOUR_KEY
python3 leads.py --city "Chattanooga, Tennessee" --source google

--source google means OSM + Google merged: the free OSM pull still runs, Google results (up to 60 per city) are added on top, and duplicates collapse into single rows tagged osm+google. Google rows usually arrive with phone numbers and full addresses — the two things OSM is weakest on.

Honesty note — read this before you enable billing. We field-test what we can run. The keyless OSM path, the --merge-csv hand-list path and the merge/dedupe engine are tested end-to-end on real cities (see ../docs/FIELD_TEST.md); the Google request path is written against the documented API format but not tested against a live key — we don't ship your kit with our billing account, and we won't claim a test we didn't run.

So: if you are thin and you want a path somebody has actually run, that's fix 3 above (--merge-csv), which needs no key and no card. Google Places is the faster option and the one with an untested last mile. If Google rejects your first request, the error message the scraper prints tells you exactly what to check, and the keyless default keeps working regardless.

Never commit or share your API key. It's tied to your card.

How it works (60 seconds)

  1. City lookup — each city name goes to OpenStreetMap's free geocoder to find the official city boundary.
  2. One Overpass query per city — asks the OpenStreetMap Overpass API (free, keyless) for everything inside that boundary tagged as a plumber (craft=plumber and friends), plus any business whose name contains plumb/rooter/drain/sewer. 2b. The metro widener — counts how many of those you could actually phone. Under 40 and it searches ~15 miles around the city centre too. If the free servers won't answer for one city in a multi-city run, that city is skipped with a clear message and the run keeps everything else.
  3. Optional Google pull — with --source google, up to 60 Places results per city join the pot. 3b. Your hand-built rows — with --merge-csv my-list.csv, everything you typed in joins the pot too, on equal terms with the scraped rows.
  4. Merge + dedupe — same phone = same business; same name in the same city = same business (unless the phones disagree). Blanks get filled from whichever source knows more.
  5. Polite website checks — for each lead with a website, it fetches the homepage once (browser identity, 12s timeout, ~1 request/second) to grab missing phones/emails and score the site.
  6. CSV out, weakest web presence first — plus lookup-list.md for hot leads still missing a phone.

The scraper is deliberately polite: one map query per city (plus up to two wider sweeps, only when that city came back thin), automatic backoff and mirror-switching when the free servers are busy, and a slow crawl over lead websites. Don't run it in a loop — once per city is all you need.

Troubleshooting

Rule zero: whatever the error is, you can paste the whole thing to your Claude and say "walk me through fixing this, one step at a time". It has this whole folder in context. Nothing below is something you have to solve alone.

Python problems (all of these are first-run problems)

python3: command not found (Mac/Linux) or 'python3' is not recognized / the Microsoft Store opens (Windows) — Python isn't installed, or on Windows you're hitting the fake placeholder Windows ships. Do ../docs/connect-claude.md, Step 2.5. On Windows, use py -3 instead of python3 from then on.

error: externally-managed-environment — your system protects its Python from stray installs. Nothing is broken. Make a private box for this folder instead, one line at a time:

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python3 -m pip install -r requirements.txt

Your prompt now starts with (.venv) — that's correct, it means the box is open. Every new terminal needs that activate line once before running the scraper. Tell your Claude "I set up a venv in the scraper folder" and it will handle it for you from then on.

"Missing dependency: requests" — either you skipped python3 -m pip install -r requirements.txt, or you made a .venv and this terminal hasn't opened it. Run the activate line above and try again.

pip: command not found — use python3 -m pip ... (with the -m), the way every command in this kit already does.

Scraper problems

"City not found" — spelling, or you gave just the city. Use --city "City, State" (or "City, State, Country" outside the US).

"Found ... but only as a map point" — that place name exists but not as a proper boundary. Add the state, or use the nearest larger city.

"Overpass API did not respond" — the free servers are busy. The scraper already retried 4 times across 3 mirrors, tried a lighter version of the same question, and it also detects the sneaky case where a mirror answers "OK" but actually ran out of time (which would otherwise look identical to "your city has no plumbers"). Wait 5 minutes and run the same command again. This is not something you did and it is not a broken kit — busy afternoons happen on free shared infrastructure.

"Could not get an answer for [city] — the free map servers are busy right now." — in a multi-city run, that one city got skipped and the run carried on. Nothing you already had is lost, and the CSV still gets written from the cities that worked. Rerun the exact same command in five minutes to pick up the missing one: the run reads the CSV that's already there, merges the new leads into it, and keeps every row exactly as you left it — so nobody gets double-counted and nothing you typed in gets overwritten. It says so on screen, e.g. "Merged into the 26 row(s) already in leads.csv from an earlier run: 9 new, 26 kept exactly as they were". A copy of the previous file is saved as leads.csv.bak first, every time. (Want a clean slate instead? Add --fresh.)

"OpenStreetMap has no plumbers mapped inside ..." (or only a handful) — map coverage varies enormously by region, and not just in small towns: some mid-size cities have only 1–2 plumbers mapped while others have 40+. The scraper already widened to the surrounding metro area automatically. After that, your fixes in order are the three in "When your city runs thin" above: neighbouring cities → the county → Google Places → 20 by hand.

Thin map data means your competitors aren't finding these leads either; it doesn't mean the plumbers aren't there.

"I only got a handful of DIALABLE leads" — read the verdict block the scraper printed; it lists your next three moves with the exact command. And don't start calling a five-name list and conclude cold calling doesn't work: you don't have a call list yet, you have the start of one. Building it is day one's actual job.

"Google Places rejected the request" — the scraper prints the specific checks: right key, Places API (New) enabled (not the old one), billing enabled on the project. Fix, or just drop --source google — the keyless default keeps working.

Few phone numbers? Normal — most no-website leads have no mapped phone either. That's exactly what lookup-list.md is for: pre-built Maps links, about a minute per number, or hand it to your Claude (the prompt is inside the file). It's also the single strongest argument for turning on Google Places, whose rows nearly always arrive with a number.

Rows full of supply houses? A map search for "plumbing" catches wholesalers and hardware stores as well as contractors. Those rows are flagged in the lead_type column and your Claude drops them at call prep. If something is flagged that shouldn't be — a contractor with "Supply" in the family name — just tell your Claude and it'll keep them.

Is this legal?

Short version: yes, and it's ordinary. You're reading a public map and public homepages, politely, to make one-to-one sales calls. That's prospecting, and thousands of small agencies do it every day.

But "is this legal" deserves more than one confident paragraph, because the rules that do bind you are real and they vary by state — the do-not-call rules, what you can and can't do by text, what has to be in a commercial email, and whether your state wants sales callers registered.

Read ../docs/rules-of-the-road.md once before your first week of calls. It's twenty minutes, it's in plain English, and it covers all of it. The two lines you already know are in there too, and they are non-negotiable: no spam blasts, and every do-not-call request is honoured instantly and forever (office/do-not-call.md, guardrail #1 in CLAUDE.md).

After the scrape

Open your Claude in this kit's folder and run prompts/01-prep-calls.md — it turns leads.csv into today's prioritized call list with openers built from the weak_web_reasons column (and fills phones from lookup-list.md if you've worked it). Then pick up the phone.

📄 playbook/week-1.md

Open file

Week 1 — From Zip File to First Close

You're the CEO now. This week has one goal: get your first paying client. Everything below assumes you've never run a business and never made a cold call. That's fine. Follow the days in order. Your Claude does the heavy lifting; you bring the hustle.

Prices below are market rates for this work, not promises of what you'll earn. Your results depend on your calls, your market, and your follow-through.


Day 1 — Setup + Build the List (about 3 hours)

Goal: kit running, your phone line sorted, and a real call list on your screen.

Day 1 is building the list. Day 2 is calling it. Nobody scrapes a city at 9am and dials 25 people at 10 — free map data is generous in some towns and thin in others. If you were told otherwise you'd feel behind by lunchtime, so here's the truth up front: today ends with a list, and most of today is one-time setup you never repeat.

  1. Connect your Claude — including Python. Follow docs/connect-claude.md start to finish, and do not skip Step 2.5, installing Python. The lead scraper is a Python program; Python is a free five-minute download and it's the single most common place people get stuck. You're done when Claude answers "what business is this?" like it runs the place, AND python3 leads.py --help prints a page of options instead of an error. (Windows: py -3 everywhere this kit says python3. Tell your Claude you're on Windows once and it handles the rest.)

  2. Fill in your details. Open config/business.yaml and edit: your name, your agency name, your city/region, your contact info. Keep the pre-filled prices unless you have a reason to change them — they're market-rate defaults ($500–$1,500 website builds, $99–$299/mo care plans).

  3. Get a business phone numberdocs/your-phone-setup.md, twenty minutes, $0–$20/month. Not optional. Run hundreds of cold calls a month from your personal cell and US carriers will eventually label it "Spam Likely", your answer rate collapses, and nothing tells you why. Do it before you dial once.

  4. Run the scraper. In Claude Code, just say:

    Run the lead scraper for my city.

    Claude runs scraper/leads.py and produces leads.csv — every plumber the free map data knows about in your area, ranked by how weak their web presence is. No API key, no account.

    Look at the box it prints at the end. The number that matters is DIALABLE NOW — leads with a phone number that look like real contractors. Row count is not a call list; a row without a phone number is not a phone call.

    How long it takes: one city, measured on our machines, has run ~1 to ~10 minutes; the slow part is the free OpenStreetMap servers, which are volunteer-run and sometimes busy. A five-city run has taken 45+ minutes. Every phase line now prints the elapsed seconds, so if the number is still moving, it is working — that is how you tell slow from hung. It prints a lot of chatter, some of which looks alarming and isn't. docs/what-you-will-see.md has the whole thing printed out in advance, screen by screen, with a note on each line saying whether it's good news, bad news, or noise. Keep it open the first time.

  5. Get to about 40 dialable leads. That's roughly a week of calling at 25 dials a day once you allow for no-answers.

    • 40+ already? Brilliant, skip to step 6.

    • Fewer? Completely normal, and not your fault — free map coverage is a lottery by town and says nothing about how many plumbers actually work near you. The scraper prints your next moves with the exact commands to paste. In order, and note that the first three are free and need no card at all:

      1. Add 3–4 neighbouring towns to the same run. Ask Claude which ones, then sanity-check them on a map — Claude is guessing from general knowledge here.

      2. Work scraper/lookup-list.md — about a minute per number. Hand it to Claude and it'll do them while you do something else.

      3. Build a list by hand and merge it in. This is the one that works in every city, including the ones the map has never heard of. You (or Claude, with web search) search "plumber near [your city]" on Google Maps, fill a copy of scraper/hand-list-template.csv, and run:

        python3 leads.py --city "Your City, State" --merge-csv my-list.csv
        

        Those rows get deduped and ranked exactly like the map rows, and any row where you filled in the website column gets that site fetched and scored too. Paste the website URL in while you're there — three seconds a row, and it's the difference between a scored lead and one marked unverified that sorts below the measured ones. Free, no key, no card, and it is field-tested (Chattanooga: 1 dialable → 4).

      4. Only if you'd rather pay for speed: turn on the optional Google Places source (~10 min, needs a card on a Google Cloud project). It is the fastest fix, not the only one, and it is the single path in this kit we could not test against a live key.

      scraper/README.md, "When your city runs thin", has all of it.

    Budget 30–90 minutes for this step honestly. It is the least glamorous part of the week and it is the part everything else stands on.

  6. Read the rules oncedocs/rules-of-the-road.md. Twenty minutes, plain English: do-not-call, what's different about texting, what has to be in a commercial email, whether your state wants sales callers registered. You never have to do it again.

  7. Optional but smart: deploy your own agency site tonight. agency-site/ has the instructions — it's a drag-and-drop deploy on Netlify, free, ~15 minutes. Having a real site makes tomorrow's calls easier ("check us out at...").

Done when: you have roughly 40 leads with phone numbers, config/business.yaml has your info, and you have a business number to call from.


Day 2 — First Calls

Goal: dials. Not sales. Dials.

How many? 10–15 today, not 25. Your business number is brand new and a new number making 30 calls on day one is exactly the pattern carrier spam filters watch for. Ramp to 25–30 over two weeks — the schedule is in docs/your-phone-setup.md. Fewer, better calls this week is not a compromise, it's the correct opening move.

Nerves are normal. The script carries you.

  1. Morning prep (20 min). In Claude Code, use the prompt in prompts/01-prep-calls.md. Paste it (or just ask Claude to run it). It turns leads.csv into today's prioritized call list with a custom opener for each lead — and it verifies anything the opener claims about their website before writing it.

  2. Print or open sales/call-script.md. It's short on purpose: opener, 3 discovery questions, pitch, price anchor, close. Read it out loud twice before your first dial. Note the three opener versions — A when you've seen their site, B when you've verified they have none, C when nobody could check. Never say "I looked you up and couldn't find you" to a business you didn't look up. That's the fastest way to lose a call, and it happens on call #1 to people who skip this.

  3. Open sales/objections.md in a second window. Top 8 objections with exact responses. You will hear "I'm too busy" and "how much?" today. Both are covered.

  4. Call 9am–11:30am local time. Plumbers answer early. Avoid 12–1 (lunch, on jobs).

  5. After every call, tell Claude what happened. One line is enough:

    Called Mike's Plumbing — no answer, left voicemail. Called Ace Rooter — talked to Dave, interested, call back Thursday.

    Claude keeps your pipeline. This is how nothing falls through the cracks.

  6. No-answers get the templates in sales/outreach.md — send the day-1 SMS/email right after the missed call. The follow-up cadence is day 1 / day 3 / day 7.

What a morning of calls realistically looks like: most won't pick up. A couple will talk. One might be genuinely interested. That's a good day and it is supposed to feel like that. Log everything.

Done when: every dial logged with Claude, follow-up voicemails and texts sent to the no-answers.


Day 3 — More Calls + Research the Warm Ones

  1. Before calling anyone who showed interest, run prompts/02-research-lead.md on them. Claude deep-dives one lead: their current web presence, reviews, what's broken, what to say. Walking into a call knowing their business beats any script.
  2. Another 10–15 fresh dials from the next batch on your list. Same routine as Day 2. (Still ramping — docs/your-phone-setup.md.)
  3. Day-3 follow-ups go out to Day 2's no-answers (templates in sales/outreach.md).
  4. End of day, ask Claude:

    Pipeline status — who's warm, who do I call back, what did I learn today?

Done when: every warm lead researched, and every dial logged.


Day 4 — Calls + Your First Real Pitch

By now someone has said "tell me more." Today you pitch properly.

  1. The pitch, in one breath: "I build professional websites for plumbers. I can have yours live this week — mobile-friendly, click-to-call, a form that catches jobs while you're under a sink. Builds like this run $500–$1,500 one-time, and I handle everything."
  2. Show, don't tell. Before a pitch call, ask Claude to spin up a preview of the plumber template from fulfillment/ with the lead's name and colors on it. "I already started a mockup for you" turns a pitch into something they can look at.
  3. Keep dialing. 15–20 more calls around your pitch appointments.
  4. The close is in sales/call-script.md. Ask for a yes, take a deposit (Stripe or PayPal link — prompts/04-invoice.md shows Claude how to set this up), and book their delivery slot: "I'll have it live by Friday."

Done when: at least one full pitch delivered. Closed or not — you pitched.


Day 5 — Close + Book the Delivery Slot

  1. Call back everyone who said "let me think about it." The script's callback line: "Wanted to catch you before I fill this week's build slots — should I hold one for you?" Scarcity is honest here: you genuinely can only build so many per week.
  2. When someone says yes:
    • Send the one-page service agreement. office/service-agreement.md — your Claude fills it from the call notes in about thirty seconds (prompts/04-invoice.md, agreement mode). It says what you're building, what you're not, that they get one revision round, that they own everything, and that you're not promising them jobs. Two minutes for them to sign by replying with their name and the date.
    • Take a deposit (half up front is standard; prompts/04-invoice.md handles the invoice). Signed agreement plus deposit is what starts a build. Both, not either.
    • Send the intake questions (in fulfillment/delivery-checklist.md — logo, photos, services, service area, license number).
    • Book the delivery slot — a real day on the calendar, this week or early next. Same-week delivery is your edge. The AI makes it possible.
  3. Day-7 follow-ups go out to Day 2's silent leads.

Day 6 — Build (or Keep Calling)

If you closed: open prompts/03-fulfill.md. It drives Claude through one of the two site templates step by step — client's branding, services, reviews, click-to-call, lead form. (Two designs ship: fulfillment/website-template/ and fulfillment/website-template-b/. Claude picks, or let the client choose — it costs nothing and makes the build feel bespoke.) A first build takes an afternoon. Deliver what you sold, exactly.

If you haven't closed yet: normal, and expected. Cold outreach is a numbers game played over weeks, not days — week 1's job was to build the list, learn the script, and start the follow-up cadence, and all three of those pay off in week 2 and 3. Keep dialing. playbook/month-1.md lays out the funnel arithmetic (clearly labeled as assumptions, because that's what it is) so you can see which stage to work on rather than guessing.

If you're running out of names, don't grind a dead list — go back to scraper/README.md and add neighboring cities to your scrape. A thin list is a supply problem, not a you problem.


Day 7 — Review + Reset

Run prompts/05-weekly-review.md with Claude. It walks the week: calls made, conversations had, pipeline state, follow-ups due, and next week's plan.

Then rest. Real businesses take a day off.


Week 1 Scoreboard

Split in two on purpose, because only the top half is yours to decide.

Effort — these are targets. Hit them and week 1 was a success, full stop.

Metric Target
Dials, days 2–6 50–75 (ramping a new number — see docs/your-phone-setup.md)
Voicemail + day-1 touch on every no-answer every one
Warm leads researched before calling back every one
Everything logged with Claude every call

Not an effort metric: how many dialable leads your area gives you. ~40 is what a week of dialling needs (50–75 dials across 3 touches each), and it is what the scraper aims at — but whether your town hands it to you is a property of free map coverage, not of how hard you worked. Measured, cold: Mesa AZ 17 dialable in the city and 25 across the metro · Chattanooga TN 1 · Toledo OH 1 · Fargo ND 1 · Sioux Falls SD 0. If you land at the low end, that is the normal case and step 5 above is the actual work of week one. Do not read a thin first scrape as a verdict on you or on cold calling.

Outcome — measure these, don't promise them to yourself.

Metric What to do with it
Real conversations Under ~10% of dials → you're calling at the wrong hour.
Pitches Conversations that never become pitches → rework the opener.
Closes Zero in week 1 is completely normal — the day-3 and day-7 touches you started this week don't even land until next week.

The only way to fail week 1 is to not build the list and not make the calls.

The rest of the box

The 🔒 files — CLAUDE.md (the operator brain), the prompt pack, the sales scripts, the fulfillment templates and the agency site — unlock when you become CEO. Market rates, not promises: the open files above tell you exactly what the machine does; the locked ones are the machine.