Internal outbound sales CRM that runs itself. Leads are discovered automatically from Google Places + OpenStreetMap across whatever niches and cities you save, then enriched, researched, AI-scored, emailed on a three-touch sequence, and booked when they reply — with a manual call/DM workflow on top.
Leads come from the Discovery pipeline, not a pre-loaded file.
There are two isolated workspaces. HVAC and trades runs from /crm/automation.
Insurance — The Learning Lantern Academy — has its own database, dashboard, API
routes, tables, switches, and sending controls. It targets insurance producers
and buyer signals across AZ, SC, VA, OH and MI.
While a pipeline's switch is off, nothing is discovered, scored, or sent. While
it is on, the schedule in ARCHITECTURE.md runs without you.
See ARCHITECTURE.md for how the pipeline, the outbox, the AI provider chains and the data model actually fit together.
npm install
npm run dev
open http://localhost:3000Supabase is required — lead discovery, scoring and outreach all read and write the database.
- Create a free project at supabase.com
- Go to SQL Editor and paste the contents of
supabase/schema.sql— run it - Go to Settings > API and copy your URL and anon key
- Create
.env.local:
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
- Also run every file in
supabase/migrations/in order (001 → 027) - Restart
npm run dev, open /crm/automation to set your niches and cities, then /crm/discovery to pull your first leads
| Variable | Why |
|---|---|
SUPABASE_SERVICE_ROLE_KEY |
Every server route. Never exposed to the browser. |
CRON_SECRET |
Guards every /api/cron/* verb and /api/scrape-phone, which the enrichment stages call over HTTP |
APP_USERNAME / APP_PASSWORD |
The CRM login. Make the password long and random — the per-IP brake slows a brute force, it does not stop one. |
SESSION_SECRET |
Signs the session cookie. Without it the key is derived from APP_PASSWORD + CRON_SECRET, and CRON_SECRET lives in GitHub Actions. |
COMPANY_MAILING_ADDRESS |
CAN-SPAM. No email sends at all until this is a real street address or PO box. |
DAILY_SEND_CAP |
Default 40 for the FSS/HVAC workspace |
RESEND_API_KEY / RESEND_WEBHOOK_SECRET |
Sending and delivery events |
MS_TENANT_ID / MS_CLIENT_ID / MS_CLIENT_SECRET / MS_MAILBOX |
Reading replies from Outlook |
SERPAPI_API_KEY / PRODUCERFORGE_OLLAMA_API_KEY |
Insurance search (100 requests/month each) |
INSURANCE_FROM_EMAIL / INSURANCE_MAILING_ADDRESS |
Optional — insurance mail defaults to the company's sender and address |
INSURANCE_SUPABASE_URL / INSURANCE_SUPABASE_SERVICE_ROLE_KEY |
ProducerForge database used only by the insurance workspace |
Production values live in Vercel, not here. vercel env pull refreshes a stale
local .env.local; a local key that has drifted will 401 while production works
fine.
Booking runs through a single Calendly link — no Google Calendar OAuth, no refresh tokens, no custom free/busy code. Calendly is already connected to the owner's calendar, so it creates the event on both sides itself.
The link lives in lib/reply-actions.ts:
export const CALENDLY_LINK =
"https://calendly.com/fullstackservicesllc/full-stack-meeting";When a reply is classified Interested, the CRM automatically emails that
link and moves the lead to Booking Link Sent. To change the link, edit that
constant.
git init && git add . && git commit -m "CRM"
git remote add origin https://github.com/PeterdQuinn/fullstack-crm.git
git push -u origin mainThen go to vercel.com/new, import the repo, add your env vars, deploy.
- Open the dashboard link
- Click a lead from the table
- Hit the Call button (dials on mobile)
- Follow the guided discovery questions in the Details tab
- Switch to Call Log tab → log the outcome, notes, pain points, what software they use
- Switch to Meeting tab → book a meeting with Peter Quinn
- Move to the next lead
29 statuses are permitted — see the leads_status_check constraint in
supabase/schema.sql and the LeadStatus union in lib/types.ts (they must
stay in sync). The main path:
| Status | Meaning |
|---|---|
| New | Discovered, not yet scored |
| Scored | Scored below the outreach bar (20), or holding a placeholder 50 awaiting re-score |
| Ready for Outreach | Scored ≥ 20 (never exactly 50) — eligible for automated email |
| Email 1/2/3 Sent | Position in the 3-touch sequence |
| Replied | Reply received, awaiting classification |
| Booking Link Sent | Classified Interested — Calendly link emailed |
| Booked | Meeting on the calendar |
| Follow-Up Scheduled | Unclear reply — follow-up task queued |
| Do Not Contact | Opted out, complained, or classified Not Interested |
| Bad Email | Hard bounce |
Tracks total leads, new leads, called today, follow-ups due, booked meetings, and interested leads in real time.
Discovery (primary). /crm/discovery runs the HVAC pipeline: Google Places
(hard-capped at 20 requests/week, enforced in the database) + OpenStreetMap
Overpass (free), AI-deduplicated, then imported. Search terms and target metros
live in lib/discovery-sources.ts.
CSV import (secondary). Click Import Leads in the header. Any CSV with
business_name, owner_name, phone, website, address, niche.
Duplicates are skipped.
The production insurance dashboard is at /insurance. Its sections have stable
URLs: /insurance/pipeline, /insurance/discover, /insurance/tasks, and
/insurance/activity. /crm/insurance remains a compatibility entry point.
Three switches are off by default:
| Switch | What it starts |
|---|---|
enabled |
Scheduled discovery, enrichment and qualification. Nothing leaves the building. |
sending_enabled |
Real insurance email, up to three touches, capped by the insurance daily budget. |
autopilot |
A reply classified "not interested" suppresses the record without you. That is the only automated action. |
Before turning sending on, open a record and hit Preview the next touch — that is exactly what a stranger receives.
Discovery only imports sources that can become a lead: quote farms, listicles and job boards are refused, carriers are refused for buyers but kept for recruiting (a captive agent is a recruiting target). Enrichment looks for a phone as well as an address, and the board says how each record can be worked — email, call, or not reachable yet. Records that turn out to be the same person are merged on a matching email, or on a matching phone and name; a shared office number alone never merges two people.
Company fit. Apply migration 022_insurance_company_fit.sql, then save a
company targeting profile for each track in the insurance workspace. Define the
actual offer, must-haves, dealbreakers and search terms. Open a prospect and use
Research company fit for a sourced assessment. Unknown required facts hold
outreach; the report shows each supporting excerpt and its source. This uses
public search excerpts, not authoritative license verification. Saving a profile
activates fit checks for that track, including already queued email retries.
Bilingual-first recruiting backend. Migration 023_insurance_bilingual_priority.sql
adds language evidence and prioritizes supported bilingual producers, then possible
language clues, then the remaining prospects. This works before company targeting
is configured. Default: any explicitly stated bilingual ability; preferred languages
can be set through the insurance action API. Search uses language-focused and
broader passes. Migration 027_insurance_multi_license.sql stores per-state license
records, and the dashboard filters agents to 2+ active states while showing status,
lines of authority, expiration, and evidence links. The board supports selecting
records and bulk stage changes, with each change recorded in the insurance timeline.
Authoritative state-list ingestion remains a planned connector; the app does not
invent license or language facts.
Verify with npm run test:insurance and npm run test:insurance-languages.
- Next.js 14 — App Router
- Supabase — Postgres database
- Calendly — Meeting booking
- Resend — Outbound email + delivery/bounce webhooks
- Google Places + OSM Overpass — Lead discovery
- Tailwind CSS — Styling
- TypeScript — Type safety
- Vercel — Deployment
npm test # 8 suites: reply policy, automation contract, AI
# normalisation, cron workflow, database retry,
# insurance, email extraction
npm run ai:health # probes every AI provider with real calls
npm run verify:live # exercises the deployed routesThe automation contract test reads the source and asserts the properties that have broken before — 207 checks — so a regression fails the build instead of the pipeline.
Every AI task (reply classification, lead scoring, email drafting, discovery
cleanup) runs through one ordered fallback chain defined in
lib/ai-providers.ts:
Ollama → Groq → Gemini → Anthropic → Kablewy
A provider with no API key is skipped rather than failing the chain. Order is
overridable per task via CLASSIFIER_PROVIDERS, SCORING_PROVIDERS,
DRAFT_PROVIDERS, CLEANUP_PROVIDERS.
Check them with npm run ai:health.
Set FIRECRAWL_API_KEY in the server environment. FIRECRAWL_API_KEYS is also
accepted as a comma-separated rotation list, and the legacy spelling
FIRE_CRAWL_API_KEY is supported. A manual Run AI Research action
uses Firecrawl Search and Map to collect dated BBB/licensing, reputation,
hiring, expansion, advertising, technology and website-footprint evidence.
Migration 014_internet_intelligence.sql stores observations and the separate
0–100 footprint / -100–100 growth-momentum scores. Firecrawl failures degrade
to the existing deterministic website and Google signals rather than blocking
research.