Uprise SOP · Outbound

SOP — Automated "Free Spec Site" Cold Outreach System

Ingested 2026-07-14 from [spec-site-outreach-sop.vercel.app](https://spec-site-outreach-sop.vercel.app/). Finds local businesses with NO website, auto-builds them a real site from a…

source: notes/spec-site-outreach-sop.mdreference

Ingested 2026-07-14 from spec-site-outreach-sop.vercel.app. Finds local businesses with NO website, auto-builds them a real site from a template, deploys it live free, emails the owner the link as the opener, tracks + follows up automatically. Foot-in-the-door offer, not a spam blast.

⚠️ HOW THIS MAPS TO UPRISE'S EXISTING INFRA (read before building ANYTHING)

Most of this pipeline already exists in Rio's stack. Do NOT rebuild from scratch — wire the missing pieces into what's live:

SOP stage Uprise equivalent (already built) Gap to build
Lead scraper (Maps, no-website filter) Scout/Apify pipeline + manual scrapes → Command D1 Automate the "no website" filter + email-hunt step
CRM Google Sheet Command D1 (uprise-command) — richer than a sheet Maybe add site-url + followup-count fields to leads
Site builder (template + placeholders) The walk previews (8 live) + ~/uprise-adgap/gen.py pattern A per-niche HTML template + builder script for volume
Deploy (CF Pages) Already the default host; <name>.pages.dev previews proven Batch deploy script
Email sender (Gmail SMTP, caps, mark-before-send) The outbound rail (~/uprise-portal/agent/outbound_send.py): SMTP, 9-5 ET, cap 10/wk, dedupe, JSON queue Follow-up sequencing + followup-count cap (rail is one-shot today)
Daily runner + cron launchd agents (rail fires 9:30/12:30/15:30) A run_daily orchestrator if/when the full loop goes live

Strategy fit note: spec sites = the old Lee/preview playbook, automated. Uprise's offer is now $1,500/mo growth engine — so the spec site is the OPENER, the pitch on reply is still Base (site included). No-website businesses are Tier-1 walk-door ICP (South End Plumbing = exactly this profile).


The pipeline (5 stages, once a day)

Scrape leads → Build site → Deploy live → Email owner → Log + follow up

Requirements

Stage 1 — Scaffold

Claude Code prompt (verbatim from source):

Set up a Python project called spec-site-outreach with this structure:
- lead_scraper.py      (finds businesses with no website in a given niche/city)
- site_builder.py      (fills an HTML template with a business's info)
- deploy.py            (pushes the built site to a free static host)
- sender.py            (sends the outreach + follow-up emails)
- sequences.py         (holds the email copy templates)
- run_daily.py         (runs the whole pipeline once a day, end to end)
- templates/           (HTML site templates, one per niche style)
- sites/               (output folder for built sites, gitignored)
- config.json          (API keys, sending account, daily limits — gitignored)
- requirements.txt

Add a .gitignore that excludes config.json, sites/, any *.token/*.pkl files,
and local logs.

Stage 2 — Lead scraper

Write lead_scraper.py using Playwright to search Google Maps for businesses
matching a --niche and --city argument. For each result:
- Skip it if it already has a website listed
- Pull business name, phone, address, and Google Maps profile URL
- Try to find a personal-domain email (gmail/yahoo/hotmail/outlook/icloud)
  via a web search/scrape API — skip the lead entirely if no email is found
- Deduplicate against rows already in the tracking sheet by business name
- Write new leads as rows in the tracking sheet

Add a --headless flag and a --max flag to cap how many leads it pulls per run.

Tip: niches with LOW website adoption (handyman, cleaning, landscaping, pressure washing, small trades) yield far more usable leads per run.

Stage 3 — Tracking sheet (CRM)

Columns: Business Name · Owner Name (blank if unknown, never a placeholder) · Phone/Email/Address/City · Niche (drives template + copy) · Site URL (post-deploy) · Site Sent + Date (marked BEFORE the send fires) · Followup Count (hard cap, e.g. 5) · Status (Active/Replied/Dead).

Set up Google Sheets access using a service account (not user OAuth) via
gspread. Write a small sheet_client.py wrapper with read_rows(), append_row(),
and update_cell() helpers, with retry + exponential backoff around every
network call so a flaky connection doesn't crash a run mid-pipeline.

Stage 4 — Site builder

One clean responsive HTML template per niche style. Real site with business name, city, services, contact section — simple beats flashy.

Build a single responsive HTML template (templates/general.html) for a small
local-service business landing page: hero with business name + niche + city,
a services section, and a contact/call section. Use placeholders like
{{BUSINESS_NAME}}, {{CITY}}, {{NICHE_DISPLAY}}, {{SERVICE_1}}, {{SERVICE_2}},
{{SERVICE_3}}, {{PHONE_DISPLAY}}, {{PHONE_DIGITS}}, {{YEAR}}.

If phone digits are missing or too short, hide phone elements with JS and
point the call-to-action at the contact section instead — the site should
never render broken if a field is missing.

Then write site_builder.py that takes a row of business data, fills the
right template based on niche, and saves the output to sites/{slug}/.

Stage 5 — Deploy

Write deploy.py that takes a sites/{slug}/ directory and deploys it to
[Cloudflare Pages via wrangler CLI / Vercel CLI] — return the live URL on
success. Handle non-UTF8 CLI output safely on Windows, and treat a deploy
failure as skip-and-retry-next-run rather than a crash.

Stage 6 — Email sender + sequences

Short, plain, human. "You built them something real — say that, and give them the link."

Write sequences.py with an initial outreach email and up to 4 follow-up
variants (short, plain text, no salesy formatting). Every template must
include a real physical mailing address and a one-line opt-out
("reply STOP/unsubscribe and I'll take you off this list") — this is a
CAN-SPAM requirement, not optional copy.

Write sender.py that:
- Sends via the dedicated Gmail account (SMTP + app password, or Gmail API)
- Marks "Site Sent" = Y in the sheet BEFORE sending, not after
  (so a crash mid-send can't cause a duplicate email on the next run)
- Spaces sends across a daily window (e.g. 9am-6pm) instead of firing
  them all at once
- Enforces a hard daily send cap
- Logs every send locally to a JSONL file immediately, independent of the
  sheet, so a crashed process still has a ground-truth record of what
  actually went out
- Increments Followup Count on each follow-up and marks Status = Dead once
  a lead hits the cap, skipping it on all future runs

Stage 7 — Daily runner + scheduling

Write run_daily.py that in one run: tops up new leads if the pipeline is
low, sends follow-ups to qualifying leads, then sends new outreach up to
the daily cap. Add --dry-run (must not send anything for real), --status
(prints pipeline counts only), and --force (bypass the already-ran-today
guard). Wrap the whole run in retry/backoff for network calls.

Schedule daily (cron/launchd). Start conservative: 10-15 sends/day per account while dialing in copy + deliverability.

Compliance — non-negotiable

CAN-SPAM on EVERY commercial email regardless of volume:

The practical risk isn't a lawsuit — it's the sending account getting reported and suspended, killing the whole system overnight. Bake into templates day one.

Deliverability: dedicated sending account always · warm up a new Gmail gradually · modest daily volume · hard follow-up cap per lead (unanswered follow-up spam = complaint trigger) · check local/state law beyond CAN-SPAM.

Lessons learned (from the source's debugging)

Related: _systems (the rail, Command), uprise-teardown-machine, uprise-cold-email-scale (the parked uprisegrowthteam.com sending domain fits this), apm-walk-route (manual spec-site precedent), 02-icp