# ULTRON — project reference (single source of truth)

> **SELF-UPDATE RULE (read first):** Whenever you add or change anything — a scraper, DB table/RPC, cron,
> credential, service, dashboard page, config, or command — **update THIS file** in the same session so the
> next Claude Code session stays in sync. This file is how memory moves chat-to-chat.

Ultron is a lead-generation platform for a **Saudi marketing agency**. It scrapes **Google Maps** business
leads through **Saudi residential proxies** on a VPS, stores them in **Supabase** (master DB), and shows them
in an **Ultron dashboard** hosted on **Railway**. Goal: beat Apollo ($59/mo) + Apify ($29/mo) on cost & volume.

## Architecture
```
Local Mac  (/Users/ahmed/devFolder/Ultron)   ← dev; edit here, deploy out
   │  rsync (to VPS)   +   git push (to GitHub → Railway)
   ▼
VPS  (Hostinger 72.61.190.110)               ← runs the scrapers (Puppeteer + Saudi proxy)
   │  upsert (pg)
   ▼
Supabase  (Postgres, master DB)              ← leads deduped by place_id + coverage + status
   │  REST reads (secret key, server-side)
   ▼
Dashboard  (Railway, code in /dashboard)     ← Ultron UI (also runs locally on the VPS :3000)
   + optional Google Sheet CRM mirror
```

## Credentials & access
**All secret VALUES live in `/Users/ahmed/devFolder/Ultron/.env` (local) and `/root/ultron/.env` (VPS).
`.env` is gitignored — never commit it.** Read `.env` when you need a value. What's where:

| System | How to access | Secrets (in `.env`) |
|---|---|---|
| **VPS** | `ssh ultron` (alias in `~/.ssh/config`, key `~/.ssh/ultron_vps`). Root @ `72.61.190.110`. Project: `/root/ultron`. | root pw set at provisioning; normal access is keyed |
| **Proxy** (Evomi, switched from DataImpulse 2026-07-05) | `core-residential.evomi.com:1000`, rotating residential, billed per GB (15GB bought). Saudi = append `_country-SA` to the **password** (not the username — opposite of DataImpulse's `__cr.sa`-on-username scheme). Switched because DataImpulse's follow-up top-ups demanded a $50 minimum after the initial $5. | `PROXY_HOST`, `PROXY_PORT`, `PROXY_USER`, `PROXY_PASS` |
| **Supabase** | ref `jvzcglbbquephmkyzbhr`. REST `SUPABASE_REST`. Direct PG: `db.jvzcglbbquephmkyzbhr.supabase.co:5432` user `postgres`. | `SUPABASE_SECRET`, `SUPABASE_DB_PASSWORD`, `SUPABASE_PUBLISHABLE` |
| **Dashboard** | Railway (from GitHub, root dir=`dashboard`). Local copy: `http://72.61.190.110:3000` (systemd `ultron-dashboard`). **Auth (2026-07-09): 100% Supabase — accounts in `app_users` (scrypt-hashed) + sessions in `app_sessions`; admin manages the team on the /team page. NO login creds in env.** Bootstrap/recover an admin with `node scripts/seed-admin.js <email> <password> [name]` (password passed on CLI, never stored). | *(none — user auth is in Supabase)* |
| **GitHub** | `git@github.com:AhmadKhalidSA/ultron.git`, branch `master`. Mac push uses key `~/.ssh/id_ed25519_ahmadkhalid`. **`/root/ultron` on the VPS is also a git repo** tracking this remote directly — push uses a dedicated deploy key (write access) at `/root/.ssh/ultron_deploy` (repo `core.sshCommand`). Railway auto-deploys on push from either side. | — |
| **Google Sheet CRM** | Apps Script webhook (`SHEETS_WEBHOOK_URL`) appends leads to the sheet. | `SHEETS_TOKEN` |
| **Email verifier** | SMTP RCPT check from VPS (port 25 open). Sender = `VERIFY_FROM` (move to a subdomain, not the main sending domain). | `VERIFY_FROM` |
| **Serper.dev** | Google Search API for LinkedIn founder discovery (`src/scrape-linkedin-serper.js`) — 2500 free queries/no card, then $1/1k pay-as-you-go (not a subscription). | `SERPER_API_KEY` |
| **jarvis-memory** (backup/watch only) | Railway FastAPI+FalkorDB memory service (lives in `claude-voice/memory-service`, not this repo). VPS crons `memory-backup.js`/`memory-watch.js` hit it via `MEMORY_API_URL` with `Authorization: Bearer $MEMORY_API_KEY`. Now in VPS `/root/ultron/.env` (mirror of `claude-voice/.env`). | `MEMORY_API_URL`, `MEMORY_API_KEY` |

**Railway env vars** to set: `SUPABASE_REST`, `SUPABASE_SECRET` only (Railway sets `PORT`). **All user auth
lives in Supabase `app_users` — there are NO login credentials in env anywhere** (the old `DASH_USER`/`DASH_PASSWORD`
env login was removed 2026-07-09; delete those vars from Railway). Infra keys (`SUPABASE_*`, and for the
seed/db-setup scripts `SUPABASE_URL`/`SUPABASE_DB_PASSWORD` in local `.env`) are API keys, not user credentials.

## The pipeline (Phase 1 collect → Phase 2 age → Phase 3 web → verify)
- **Phase 0 — Orchestrator** `src/run-plan.js` — runs `config/plan.json` (category × city list) sequentially,
  one fresh `run-grid` child per job (Khobar → Dammam → …). Each child is **detached (own process group)**
  with a **watchdog** (`JOB_TIMEOUT_MIN`, default 120): if a city hangs it's SIGTERM'd (run-grid closes Chrome
  cleanly) then the whole group SIGKILL'd, so the plan always advances and never orphans Chrome. `ONLY=` filters.
  Launch detached so it survives ssh: `setsid bash -c "JOB_TIMEOUT_MIN=240 exec node src/run-plan.js >> data/plan.log 2>&1" </dev/null &`
  After each job it appends `{keyword, city, exit, ts}` to `data/plan-done.jsonl` (the completion ledger).
- **Phase 0.5 — Self-driving layer** `scripts/plan-manager.js` (cron */15min) — **`config/plan.json` is now
  GENERATED; steer the scraper by editing `config/backlog.json`** (`jobs` + `revisit_days` 14 + `retry_failed_hours` 6).
  If no scraper is running (run-plan/run-grid/mahally) and backlog has due jobs (never done, done > revisit_days
  ago = perpetual refresh, or failed > retry_failed_hours ago = auto-requeue), it regenerates plan.json and
  launches run-plan detached (with **`JOB_TIMEOUT_MIN=1440`** — Ahmed's 2026-07-04 call: each city gets up to
  24h so big cities like Riyadh/Jeddah actually finish their grid instead of being cut at ~8% by the old
  240min cap), then Telegrams Ahmed one note. Completion state = `scripts/plan-state.js` (merges
  plan-done.jsonl + current plan.log section). **Pause: `touch data/plan-manager.off`**. NOTE: revisit_days
  drives proxy-GB burn — full re-scans cost bandwidth. **2026-07-18: SCRAPING PAUSED (plan-manager.off set,
  Ahmed's call) — the VPS RAM now serves the `ultron-wa` WhatsApp workers; rm the .off file to resume.**
- **Ops (zero-token monitoring)** — plain-script crons that message Ahmed directly via the Telegram bot HTTP
  API (`scripts/notify.js`; token read from `/root/.claude/channels/telegram/.env`, chat id `OPS_TELEGRAM_CHAT`
  in `.env` — no LLM involved): `scripts/ops-digest.js` (04:00 UTC = 07:00 Riyadh daily digest: funnel, yesterday's
  haul, plan status, cron recency, RAM/disk) and `scripts/ops-watch.js` (*/5min: wedged-plan, low-RAM <250MB,
  disk ≥90%, orphan proxy-Chrome — detected via the `proxy-server=gw` cmdline fingerprint; alert-only by design,
  2h cooldown per alert type, state in `data/ops-watch-state.json`).
- **Phase 1 — Collector** `src/run-grid.js "coffee shop" "Al Khobar"` — grid-tile crawl (viewport search per
  tile + `mouse.wheel` scroll), extracts everything **Google Maps exposes** (name, phone, GPS, category,
  address, rating, reviews, website URL, maps link). No website crawl (email/socials = Phase 3; phone lands
  here). **`city` derived from GPS** via `geo.cityFromCoords`, never the search term.
  **ADAPTIVE SUBDIVISION (mandatory):** base grid = `step`° (0.015 ≈ 1.6km) at `zoom` (16). KEY INSIGHT:
  Google returns the ~120 **nearest** results regardless of zoom, so a FINE BASE GRID (many distinct centers)
  is the real coverage lever — deep zoom-subdivision alone just re-queries the same set. A tile ≥ `saturation`
  (85) links is split into 4 (zoom+1) to `maxDepth` (2), but a child branch is **SELF-TERMINATING** — it only
  recurses while it keeps finding NEW places (`willSplit = saturated && depth<MAXDEPTH && (depth===0 || tileNew>0)`),
  so dense pockets dig deep and covered/sparse ones stop (no 4^depth blow-up). Depth-first, **center-first order**
  (dense core first). Resumability: skips **`done`** tiles within `FRESH_DAYS` (7); **`saturated` AND `stalled`
  tiles re-run** (incomplete). **Skips place_ids already in the DB** on sight (`REFRESH=1` re-opens).
  Robustness (unattended): page recycled every 30 tiles, one retry w/ fresh page (new IP) on throttle, city
  ABORTS after `MAX_FAILS` (6) consecutive fails, `MAXTILES` (6000) safety cap, pg timeouts (see supabase.js),
  SIGTERM/crash handlers kill Chrome (no orphans). **Early-stop is OFF by default** (`STOP=1` to enable) — with
  center-first depth-first order it could false-trip on sparse first tiles and skip the core. Flags: `RESCAN=1`,
  `REFRESH=1`, `TILES=N`, `STOP=1`, `MAXTILES=N`, `FRESH_DAYS=N`. Tile keys `(+lat).toFixed(4),(+lng).toFixed(4),zoom`.
- **Phase 2 — Review enricher** `src/enrich-reviews.js [limit]` — opens each place, reads **oldest & newest
  review dates**, sets **age-based freshness**. Cron every 2h.
- **Phase 3 — Web enricher** `src/enrich-web.js [limit] [conc]` — for leads with a website & no
  `web_enriched_at`, does a **browserless, proxy-free `fetch()`** of the site (homepage + one contact/about
  page) and **COALESCE-fills** email + socials (never wipes). ~300 sites/min; ~10x cheaper than the old inline
  crawl and zero proxy GB. If the "website" is itself an Instagram/wa.me/social link, it reads the handle from
  the URL and skips the fetch (won't poke IG from our IP). Cron hourly (:37). Uses `website.js:harvestUrl`.
- **Email verifier** `src/verify-run.js [limit]` (+ `verify-email.js`) — syntax→MX→SMTP RCPT (never sends)
  →catch-all/role/disposable. Cron every 30min (retries `unknown`).
- **Legacy** `src/index.js` — text-search scraper (reads `config/targets.json`); pushes to Google Sheet + Supabase.
- **New source — Mahally** `src/scrape-mahally.js [outFile] [targetCount] [maxFetch]` — standalone scraper for
  Salla's directory of Saudi online stores (mahally.com), separate from the Maps `leads` pipeline (writes its
  own CSV, not yet wired into Supabase). Discovery via `mahally.com/ar/browse?query=<category>` (product
  search that surfaces merchant store links — note the required `/ar/` locale prefix, omitting it silently
  breaks the page). Bot-protected (Cloudflare), so uses the same proxy+stealth browser as Maps; retries once
  on a Cloudflare "Error 1101" edge glitch. Store pages expose name/city/rating/review_count/description,
  sometimes product_count and a CR (commercial registration) number embedded in free text — CR-number regex
  is unreliable (~1/130 hit rate as of 2026-07-03, number sometimes preceded by "سجل" between "رقم" and the
  digits) and needs tightening. No native "newest"/"lowest sales" sort exists on the site — review_count is
  used as a proxy (a `TARGET`-sized "mid-band" slice, 30th–80th percentile by review count, is selected as
  the deliverable so it's neither brand-new/zero-review nor mega-established). Kept at concurrency 2 to avoid
  fighting the live Maps scraper for CPU. Not scheduled/cronned — run manually until proven out.
  Outputs both `.csv` and `.xlsx` — CSV alone (even with a UTF-8 BOM) isn't reliably rendered by
  Google Sheets' mobile CSV importer for Arabic text (BOM made it *worse* on Android, not better);
  xlsx has no encoding ambiguity. Uses the `xlsx` (SheetJS) npm package **write-only** — that
  package has known parse-time vulnerabilities (prototype pollution, ReDoS) with no npm-side fix;
  never use it to read/parse an externally-supplied xlsx file without patching first.
  **Companion enricher** `src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]` — for rows with a
  `maroof_url`, pulls the real email + customer-service phone + legal business name off that
  Maroof (Saudi e-commerce trust-verification) listing. Maroof is a client-rendered SPA: a direct
  hard navigation to `maroof.sa/<id>` loads an empty shell (confirmed live 2026-07-08) — the fix is
  a FRESH page per listing, load the homepage, then a client-side `history.pushState` + `popstate`
  to the target id, then a flat ~5.5s wait (NOT polling — repeated `page.evaluate()` calls to poll
  for readiness interfered with the SPA's own render and made it reliably fail; a flat wait is what
  actually works). About 1-in-3 attempts don't finish in time (proxy timing variance) — one retry
  recovers nearly all of those. **Caveat confirmed live 2026-07-08: relying on Mahally's
  description text to mention a maroof_url is a near-dead end** — 0/48 in a real F&B batch had one
  (matches the ~1-in-130 CR-number mention rate already noted below). Cross-referencing by STORE
  NAME via Serper (`site:maroof.sa "<store name>"`) instead of waiting for an in-text mention
  found a real match in a quick 1/3 test — likely the better path forward, not yet built as a
  standalone script. Also note: Mahally's category search (`CATEGORIES`/CLI category-filter arg,
  added 2026-07-08) is fuzzy, not a strict filter — a "coffee/food" search pulled in some pet-
  supply and toy stores too.
- **New source — LinkedIn founders/CEOs (Saudi)** two standalone scripts, separate from the Maps
  `leads` pipeline (own CSV, not wired into Supabase yet): `src/scrape-yc-saudi.js [outFile]` scrapes
  Y Combinator's own public company directory (no login, no ToS issue) for Saudi-based portfolio
  companies' founders — small pool (4 companies as of 2026-07-08), supplementary only.
  `src/scrape-linkedin-serper.js [outFile] [targetCount]` is the main volume source: runs
  `site:linkedin.com/in/ "<title>" "<city>" "Saudi Arabia"` queries — cartesian product of
  small-business-leaning titles (**Owner, Founder, Managing Partner, General Manager** ahead of
  CEO — Ahmed's actual target is <50-employee companies for his marketing/sales agency, and small
  /family-run businesses use "Owner" where large corporates almost never do, so title choice
  approximates LinkedIn's own company-size filter without needing a login session to use it)
  across 17 Saudi cities — through **Serper.dev's Google Search API** (`SERPER_API_KEY` env var —
  2500 free queries, no card, then $1/1k pay-as-you-go, NOT a monthly subscription; 500 unique
  profiles cost well under 100 credits as of 2026-07-08). Deliberately does NOT scrape linkedin.com
  or google.com directly — both (plus Bing and DuckDuckGo) silently bot-block automated queries
  within one request; Serper's own infra runs the actual Google query so there's no LinkedIn
  login/cookie/ban risk at all. Yields name + title + LinkedIn URL from Google's public snippet
  text (regex-parsed from the result title, format varies). `src/enrich-linkedin-email.js
  <inFile.csv> [outFile.csv]` is a best-effort follow-on: guesses a company from the title,
  guesses+verifies a domain, harvests an email via the existing `website.js`. Lossy by nature —
  live-tested at ~12% hit rate (7/60), and a few of those had a mismatched email/site domain
  (false positive) — always spot-check before outreach, never trust it blindly. Phone is not
  attempted at all here (Google/LinkedIn snippets never carry it, unlike Maps businesses).

## Data model — `leads` (deduped by `place_id`)
`place_id`(PK), company, category, phone, email, email_status, email_verified, email_role, **web_enriched_at**
(Phase-3 attempt marker), website, instagram/facebook/tiktok/snapchat/whatsapp/youtube/twitter, rating,
reviews, **review_tier**, **freshness**, oldest_review, newest_review, city, district, country, address,
lat, lng, maps_url, keyword, source, first_seen, last_seen.
**Person-level columns (added 2026-07-16 for the bigasscrm import; scraper never writes them):** full_name,
first_name, last_name, job_title, seniority, department, linkedin, company_size, priority, notes, alt_emails,
alt_phones, followers(int), state, source_detail (import provenance, e.g. Apollo source-file list), date_added
(raw string; parsed date goes to first_seen), **industry** (restored 2026-07-16, `leads_industry_idx`; the
dropped bigasscrm "Industry / Business Type" xlsx column — backfilled by `scripts/backfill-industry.py` +
`scripts/upload-industry.js`). Plus `leads_source_idx` on source (powers the Platform filter).
Other tables: `scan_ledger` (grid coverage), `scraper_status` + `vps_status` (dashboard heartbeats),
`leads_backup_20260716` (pre-import snapshot, see below).
**Platform-aware filtration (schema.sql §13; v2 2026-07-18):** the Platform dropdown has exactly **two buckets**
today — `'Google Maps'` and `'bigasscrm'` (WhatsApp folds into bigasscrm; future sources keep their name).
Bucketing is a query-level CASE only — `leads.source` is never rewritten. `get_sources()` returns the groups;
`get_filter_options(p_source)` feeds per-platform dropdowns; `search_leads` v4 params: `p_industry`/`p_seniority`/
`p_job_title`/`p_department`/`p_state`/`p_size`/`p_rating`/`p_vol`/`p_presence`/`p_has_wa` + person-field search.
**Normalization helpers (options + matching share them, change together):** `norm_state()` (folds the 5
Province/Region/diacritic dupes), `size_band()` (company_size raw headcounts + Apollo "51 - 200" strings → 7
canonical bands), `vol_band()` (reviews int → the 5 review_tier bands — used because review_tier the COLUMN is
~99% unbackfilled). Seniority options are comma-split + case/underscore-folded tokens; matching is
token-contains (picking "Manager" also hits "Senior, Manager"). Category options exclude Maps UI junk
(`'add %'` → "Add hours" etc.); city options need count≥5 (kills bigasscrm garbage values like "8844").
**Per-platform UI (leads.html):** Maps shows Category/City/Freshness/Rating/Review-volume; bigasscrm shows
Category (curated import taxonomy, 100% filled — the primary CRM filter)/Position/Seniority/Department/
Industry/Company-size/City/Region; Phone/Email/Web-presence/WA-history always on. Data facts (profiled
2026-07-18): bigasscrm `followers` is 100% EMPTY (dead col), `priority` 98% empty, `department` 2.3% filled;
raw `industry` has 1,311 messy values (Arabic + snake_case + city-suffixed dupes) — `category` is the clean one.
Phone preference: `scripts/phone-swap.js` puts the personal Saudi mobile
first in `phone` (company number → alt_phones) for bigasscrm rows.

### bigasscrm import (2026-07-16)
Ahmed's 46,189-row master CRM (`UltronCRM.xlsx`, mostly Apollo person-level contacts) imported into `leads`
as **source='bigasscrm'**: 46,188 data rows → 11,263 in-file duplicates dropped (synthetic PK
`place_id = 'bigass:' + sha1(lower(email|phone|company|full_name))`, keep-first) → **34,925 inserted**.
Import path: xlsx→CSV via python3+openpyxl (NEVER the npm `xlsx` package for parsing — see gotchas), scp to
VPS, batched `INSERT … ON CONFLICT (place_id) DO NOTHING` (never touches non-bigasscrm rows). Phones
normalized to 05XXXXXXXX where derivable (so waLink/WhatsApp filter work), else raw. **Safety backup taken
first: `leads_backup_20260716` (25,484 rows = full pre-import leads copy) — drop it once the import is
proven good.** One-off scripts kept at `/root/ultron/tmp-bigass-import/` on the VPS.

### Freshness vs Review Volume (IMPORTANT — they are different)
- **review_tier** = count only (descriptive): `0-5 / 5-50 / 50-100 / 100-500 / 500+`.
- **freshness** = real AGE from the oldest review (Phase 2): **HOT-NEW ≤3mo · RECENT ≤12mo · GROWING ≤36mo ·
  ESTABLISHED >36mo**. Never label newness by review count.

## File map
- `src/browser.js` Chrome launch (proxy auth, stealth, image/font blocking)
- `src/maps.js` search+scroll (**real `mouse.wheel`**, not scrollTo), extractDetails, `coordsFromUrl` (reads
  both `@lat,lng` and `!3d!4d`), `placeId`, `reviewTier`, `ageFreshness`, `extractReviewDates`
- `src/crm.js` `toRow` (Google-Sheet 38-col) + `toDbRow` (Supabase snake_case)
- `src/website.js` email + social harvesting — `harvest(html)` (regex engine), `harvestUrl(url)` (browserless
  `fetch`, Phase 3), `crawlWebsite` (legacy Puppeteer path, kept for retries)
- `src/supabase.js` pg pool, `upsertLeads` (**COALESCE never-wipes** existing data), `updateStatus`, `query`
- `src/geo.js` `cityFromCoords(lat,lng,cities)` — nearest-city-center (handles overlapping Eastern-Province
  boxes), returns null beyond ~33km. Used by run-grid (labeling) + scripts/fix-cities (cleanup)
- `src/sheets.js` Google Sheet webhook push · `src/output.js` CSV writer
- `src/run-plan.js` (Phase 0 orchestrator) · `src/run-grid.js` (Phase 1) · `src/enrich-reviews.js` (Phase 2) ·
  `src/enrich-web.js` (Phase 3) · `src/verify-run.js` · `src/verify-email.js` · `src/index.js`
- `scripts/` `db-setup.js` (applies `db/schema.sql`), `heartbeat.js`, `fix-cities.js` (GPS-relabel via geo.js,
  `DRY_RUN=1` to preview), `grid-test.js`, `debug-scroll.js`, `test-sheet.js`, `notify.js` (direct Telegram
  bot push, also CLI: `node scripts/notify.js "msg"`), `plan-state.js` (job-completion map), `plan-manager.js`
  (self-driving refill), `ops-digest.js` (daily digest), `ops-watch.js` (alerting),
  `memory-backup.js` (nightly gzipped `/export` dump of the jarvis-memory graph → `data/memory-backups/`,
  rotate 21 daily + monthlies, alert-on-failure, `-SUSPECT` save if a dump has <½ the prior memory count),
  `memory-watch.js` (*/30min jarvis-memory `/health` watchdog: service-down, stale nightly reflection >36h,
  >50% memory-count drop = wipe; log-only until the new `/export`+`/health` endpoints deploy)
- `db/schema.sql` (tables + RPCs) · `google-apps-script.gs` · `config/cities.json` (bboxes/step/zoom/**saturation**/**maxDepth**)
  · `config/plan.json` (Phase-0 job list) · `config/targets.json`
- `dashboard/` `server.js` + `index.html` (CRM home) `leads.html` `ops.html` `login.html` **`clients.html`
  `team.html` `pipeline.html` (kanban funnel + My Day tasks) `connections.html` (team WhatsApp QR linking)
  `whatsapp.html` (messenger) `campaigns.html` (WhatsApp drip campaigns + warmup, schema §21)
  `flows.html` (WhatsApp flow builder + AI-reply brain, schema §22)**
  + `assets/ultron.css` & `assets/ultron.js` (**shared design system — v2 "soft-dark premium" since
  2026-07-18 (Ahmed hated v1's red-terminal look): Inter type, near-black surfaces, indigo accent,
  FIXED SIDEBAR nav injected by U.nav() — pages keep their old `<header class="top">` markup which CSS
  turns into a slim topbar; legacy `--red*` tokens ALIAS the accent so old inline styles keep working,
  don't remove them. v1 preserved at git tag `ui-v1` + `dashboard-v1-backup/`. Home = CRM command
  center (KPIs, My Day, per-client pipeline cards, outreach chart); the coverage MAP + scraper/proxy
  panels were REMOVED from home (RPCs still exist — resurrect on /ops if wanted).**
  (rest of the shared system: tokens, Fira Sans/Code,
  Lucide SVG sprite, **role-aware `U.renderNav` (desktop+mobile from one list; Team = admin-only)**,
  modals→bottom-sheets on mobile, lead cards, shared lead-detail modal `U.openLead` — now with **CRM
  sidecar** (client assignment chips + notes timeline + contact history), `U.logContact` + client-picker
  sheet, `U.activeClient` switcher context). index.html loads **Chart.js** (CDN) for the contacts line chart.
  Server serves `/assets/*` unauthenticated; **all files are cached in RAM at startup → restart the
  dashboard after ANY dashboard file change**. Mobile ≤720px: tables swap to `.cards`, nav → bottom bar.
- Supabase RPCs: `get_dashboard_stats` (recent[] includes place_id/email_status/rating), `get_coverage`
  (lead-density heatmap, coarse ~5.5km buckets from `leads` — silently omits tiles scanned-but-empty),
  `get_tiles` (ground-truth scan coverage straight from `scan_ledger`: tile_lat/lng/zoom + done/saturated
  status, powers the dashboard's TILES map mode added 2026-07-04), `get_points`, `get_ops`, `search_leads`
  (has `p_phone`: `all`/`yes`=any phone/`mobile`=Saudi 05… i.e. WhatsApp-able; **+ `p_source` (2026-07-16):
  `all` or exact source, null/'' coalesced to 'Google Maps' — the Leads-page Platform dropdown; + `p_client`/
  `p_assigned_only`: when a client is active on the Leads page each row carries `last_contact_by/at/channel`
  for the dedup badge; defined LAST in schema.sql since it references the CRM tables**), `get_lead`,
  **`get_sources`** (distinct coalesced `leads.source` values → Platform dropdown, `/api/sources`). **CRM RPCs (2026-07-09):**
  `get_my_clients`, `get_clients`, `get_lead_crm`, `get_contact_stats`, `get_users`, `get_team_activity`.
- **WhatsApp**: `ultron.js:waLink(phone)` turns a Saudi mobile (`05XXXXXXXX`) into `wa.me/9665XXXXXXXX`
  (null for landlines/toll). Leads table has a green **WA** column; the lead detail modal shows **Call** +
  **WhatsApp** action buttons; leads page has a **Has WhatsApp** phone filter (combine with HOT-NEW etc.).

## CRM layer — clients, team, contact logging (added 2026-07-09)
A sales-CRM built on top of the leads DB so an agency team can work the leads for multiple clients without
double-contacting the same business. All state lives in Supabase; the dashboard reads via RPC and writes via
PostgREST table endpoints (server helpers `ins`/`upd`/`del`, service key server-side only).
- **Tables**: `app_users` (email, name, `role` admin|agent, scrypt `pw_hash`/`pw_salt`, active) · `app_sessions`
  (random token → user, 30d, revocable) · `clients` (profile + `color` for charts + archived) · `lead_clients`
  (lead↔client M:N assignment) · `user_clients` (user↔client team membership M:N) · `lead_notes` (per-lead notes
  timeline) · `contacts` (the touch log: place_id + client_id + `channel` call|whatsapp + contacted_by + ts —
  **the dedup + analytics core**).
- **Auth/roles**: login by email/password (scrypt in `server.js` `hashPw`/`verifyPw`, no deps). Session cookie
  `ua`=random token, validated against `app_sessions` with a 60s in-memory cache (so kick/reset propagate ≤60s).
  **admin** = full access incl. the **/team** page (add member → generated password shown once, reset pw, change
  role, activate/deactivate, delete, assign clients) + who-contacted-whom analytics. **agent** = everything
  except team management. Break-glass env admin = `id:null, role:admin`. Server route guards enforce it
  (`/team` 302s agents; `/api/users`,`/api/user/*`,`/api/team-activity` → 403).
- **Client context / contact logging**: Leads page has a **client switcher** (`U.activeClient`, persisted in
  localStorage). With a client active, **Call/WhatsApp log a `contacts` row for that client** then open the link,
  and rows show an **"already contacted by X · date" dedup badge** (from `search_leads` `p_client`). With no
  active client, Call/WhatsApp pop a **client-picker sheet** first. Agents self-join client teams (or admin
  assigns them); the switcher lists the user's clients (admin sees all). Socials are NOT logged — only Call/WhatsApp.
- **Detail modal CRM sidecar** (`/api/lead-crm`): assign/unassign client chips, notes timeline (add/delete), and
  contact history for the lead.
- **Dashboard**: "Client outreach · contacts per day" line chart (Chart.js, one line per client, `/api/contact-stats`,
  7D/30D/90D). Recent-leads panel kept.
- **API routes** (all in `server.js`): reads `/api/me` `/api/clients` `/api/lead-crm` `/api/contact-stats`
  `/api/users`(admin) `/api/team-activity`(admin); writes (POST) `/api/client/save` `/api/client/archive`
  `/api/client/join` `/api/client/leave` `/api/lead/assign` `/api/lead/note` `/api/lead/note/delete` `/api/contact`
  and admin `/api/user/create|reset|role|deactivate|delete|assign-client`.
- **Setup/deploy**: `node scripts/db-setup.js` (schema) then `node scripts/seed-admin.js <email> <password> [name]`
  (upsert an admin; password on the CLI, only its scrypt hash is stored — nothing in env). This same command is the
  **lost-admin recovery** path (there is no env break-glass). New dashboard files (`clients.html`, `team.html`) are
  RAM-cached → **restart `ultron-dashboard` after deploy** (Railway redeploys automatically on push).

## CRM funnel layer (2026-07-18 — schema §14, plan in CRM-PLAN.md "RESTRUCTURE")
Lead-centric (Close model), NO deals table: the stage lives on `lead_clients` (per-client lane), so one
lead can sit at different stages for different clients. Tables: `pipelines` (one default per client, seeded
+ insert-trigger `lead_clients_default_stage_t`), `pipeline_stages` (7-stage template New→Contacted→Replied
→Interested→Meeting/Quote→Won|Lost, custom per client), `lead_stage_history` (append-only, powers timeline
+ conversion analytics), `tasks` (follow-ups; "no answer, retry" is a TASK not a stage). RPCs:
`ensure_default_pipeline`, `set_lead_stage` (atomic move + history), `get_pipeline_board` (kanban, self-heals
new clients), `get_lead_stagectx` (modal stage selectors), `get_my_tasks` (My Day, Riyadh-tz buckets),
`get_lead_timeline` (unified feed: contacts+notes+stage moves+team WA messages — defined in §15 since it
joins wa.*). UI: `/pipeline` (drag-drop kanban + My Day strip; drag = set_lead_stage), lead modal "Pipeline"
section (stage select per assigned client + follow-up quick-add). Server routes: `/api/board` `/api/timeline`
`/api/stagectx` `/api/my-tasks` + POST `/api/lead/stage` `/api/task/save` `/api/task/done`.

## Multi-user WhatsApp (2026-07-18 — schema §15 + `ultron-wa/` service, plan in CRM-PLAN.md R4)
Each employee links their own WhatsApp number(s) by QR from `/connections`; a systemd service **`ultron-wa`**
on the VPS (supervisor + one forked Baileys worker per number, Baileys pinned 6.7.23, the daemon.js knobs
reused verbatim) syncs messages to `wa.*` and auto-links to leads via `lead_phones` (LID discipline from
ultron_sync.js). Schema §15: `wa_accounts` (per-user registry; synthetic `mac-reader:*` row = the Mac daemon,
hidden from UI), `wa_auth_state` (Postgres Baileys auth — creds + ALL signal keys), `wa_lid_map` (shared),
and **wa.threads/wa.messages repivoted to `(owner_account_id, peer_number)`** — the Mac's `ultron_sync.js`
was updated in lockstep (ensureAccount + composite conflict targets). `get_lead_conversation` v2 adds
per-thread employee attribution + stage. Dashboard proxies the supervisor's control API (`WA_CTRL_URL` +
`WA_CTRL_TOKEN` in `.env`, control port 3100 bound 0.0.0.0 for Railway; QR streamed browser-ward over SSE
`/api/wa/qr-stream`). Auto-reply (DeepSeek ack, opt-in per number) is reply-only + capped + kill-switched
(`WA_AUTOREPLY_KILL=1`). Ops: `systemctl {status,restart} ultron-wa`; RAM ~80MB/socket (scrapers paused).
**Linking gotchas (root-caused 2026-07-20, in memory [[whatsapp-reader-truths]] + [[supabase-ipv6-pooler]]):**
(1) browser identity MUST be the Baileys default `['Ubuntu','Chrome','22.04.4']` — `['Mac OS','Chrome',*]`
gets a pre-QR 428, a custom name ('Jarvis Reader') fails the post-scan link; (2) the Mac link-bridge/pair-local
reach Supabase via the IPv4 pooler (`SUPABASE_POOLER_HOST=aws-1-ap-southeast-2.pooler.supabase.com`, user
`postgres.<ref>`) because the direct db host went IPv6-only; VPS keeps the direct host (has IPv6).

### WhatsApp messenger page — `/whatsapp` (2026-07-20, schema §20)
Two-pane messenger (`dashboard/whatsapp.html`), renamed from **Numbers** (nav item `whatsapp`, `message-circle`,
`mob:true`). Left = thread list with an **account switcher** (admin: "All numbers (everyone)" + every account;
agent: only own — scoped by `get_wa_accounts(p_user, p_all=isAdmin)`), CRM **name-matched** (peer number →
`lead_phones`→`leads.full_name`/`company`). Right = conversation (reuses `.wa-bubble`) + composer + per-number
**AI toggle** + **contact-360** button (`U.openLead(place_id)` when the peer resolves to a lead). Top-right
**Add number** opens the ported QR-connect modal; **+** in the list opens a New-message modal. Schema §20 RPCs:
`get_wa_threads(p_user,p_all,p_account,p_search,lim)` (left pane) + `get_wa_conversation(p_account,p_peer,lim)`
(right pane) — both hard-filter `visibility='team'`. **SENDING (new):** `POST /api/wa/send` → supervisor
`POST /send` → `child.send({cmd:'send'})` → worker `sock.sendMessage`; the worker persists the outbound row
under the thread's own `peer_number` (LID-safe) and Baileys' echo is deduped by the `(owner_account_id,
peer_number,wa_id)` conflict key. Server routes: `/api/wa/threads`, `/api/wa/conversation`, `/api/wa/send`.
`/connections` still serves the old page for back-compat. Future (Ahmed's plan): AI-reply rules/automation UI,
media send, more per-number settings — the page + `autoreply.consider` hook (worker.js) are the extension points.

### WhatsApp campaigns + warmup — "Smartlead for WhatsApp" M1 (2026-07-26, schema §21)
A PACED outbound layer that sits ON TOP of the ultron-wa transport — it adds NOTHING to auth/link/receive.
A campaign = a **client** + one-or-more of the team's **numbers** + a **recipient queue** + a **message
template** (spintax `{a|b}` + `{name}`/`{company}` vars, optional PDF/image) + a **Smartlead-style schedule**.
Schema §21 tables (all `public.`, additive): `wa_campaigns` (client_id, status draft|running|paused|done,
message_template, media_key/type/name, send_window_start/end '09:00'–'16:00', timezone 'Asia/Riyadh',
active_days int[] `{0..6}` Sun–Thu default, sends_per_hour 8, daily_cap_per_number 40, jitter), M:N
`wa_campaign_numbers`, the send queue `wa_campaign_recipients` (place_id, canonical phone, status
queued|sent|failed|replied|skipped|opted_out, assigned_account_id, sent_at, wa_id, attempts), plus warmup
`wa_warmup_config` (per-account enabled/mode maintenance|ramp/msgs_per_day/ramp_start_day) + `wa_warmup_peers`.
RPCs: `get_campaigns(p_user,p_admin)`, `campaign_stats(p_id)`, `get_campaign(p_id)`, `campaign_add_recipients
(p_campaign,p_place_ids[])` (queues only Saudi-mobile leads, dedups on (campaign,place_id)),
`campaign_mark_replies(p_campaign)` (flips sent→replied when the peer's thread `last_inbound_ts` > `sent_at`),
`get_warmup(p_user,p_admin)`.
**Engine (in the supervisor process — the only one that sees every connected number):** `ultron-wa/pacer.js`
(new) ticks every ~25s: sweeps replies, reaps crash-orphaned claims, and for each RUNNING campaign in its
window on an active day drips ONE send per eligible number — per-number daily cap + a `sends_per_hour`
cooldown with human jitter, rotating across numbers, rendering spintax/vars, skipping opted-out peers
(`wa.threads.auto_reply_last_ts='infinity'`), claiming rows with `for update skip locked`. `ultron-wa/warmup.js`
(new) drives light two-way chatter among a user's own connected numbers + `wa_warmup_peers` (maintenance =
a few msgs/day for AGED numbers; ramp = ~5→20/day growth for NEW ones). BOTH send via the new reusable
`sendToWorker()` in supervisor.js (mirrors handleSend's IPC round-trip; existing `/send` path untouched) and
are gated by env kill switches **`WA_CAMPAIGN_KILL=1`** / **`WA_WARMUP_KILL=1`** (mirror `WA_AUTOREPLY_KILL`).
`worker.js` `doSend` was extended MINIMALLY to accept optional `{mediaUrl,mediaType}` (document|image, fetched
& sent with the text as caption; fails OPEN to text) — the text path is byte-for-byte unchanged. The pacer
signs `media_key` from the `crm-files` bucket (reuses SUPABASE_REST/SECRET from the shared `.env`).
**Dashboard:** `/campaigns` page (`campaigns.html`, nav key `campaigns`, `megaphone` icon) — campaign cards
with sent/replied progress, a New-campaign flow (client → numbers → message+media → schedule prefilled with
the middle-ground defaults → save draft / save & add leads), an Add-leads picker (reuses `search_leads`
filters; server forces mobile-only), a detail modal (recipient list + start/pause/refresh-replies/delete),
and a Warmup panel (per-number on/off, mode, msgs/day, partner list). Server routes (server.js): GET
`/api/campaigns` `/api/campaign` `/api/campaign/stats` `/api/warmup`; POST `/api/campaign/save`
`/api/campaign/start` `/api/campaign/pause` `/api/campaign/delete` `/api/campaign/recipients`
`/api/campaign/refresh-replies` `/api/warmup/save` (client-team-scoped guards).

### WhatsApp FLOWS + AI-reply brain — "Smartlead for WhatsApp" M2 (2026-07-27, schema §22)
The conversation layer that sits ON TOP of M1 + the transport — again NOTHING touches auth/link/receive. A
**FLOW** is a node-graph (JSON) owned by a client that drives each lead through the client's existing
`pipeline_stages` as a WhatsApp conversation progresses; a **FLOW-RUN** is one lead's live cursor through it.
"Negotiation needs a human" = a `handoff` node that stops automation and creates a `tasks` row for the lead
owner. Schema §22 (all additive): `public.wa_flows` (client_id, name, `graph jsonb` {entry,nodes[],edges[]},
status draft|active|archived, created_by), `public.wa_flow_runs` (flow_id, place_id, client_id,
current_node_id, status active|waiting|done|handoff|stopped, `wait_until`, `context jsonb` per-lead vars incl.
`last_inbound`, account_id, peer_phone; UNIQUE(flow_id,place_id) = one run per lead), `alter clients add
kb_doc` (the per-client knowledge base the AI node reads), `alter wa_campaigns add flow_id` (a campaign
auto-starts a run when a recipient replies). RPCs: `get_flows(p_user,p_admin)`, `get_flow(p_id)` (flow +
graph + client_kb + run counts), `get_flow_runs(p_flow)` (runs monitor), `start_flow_run(p_flow,p_place,
p_account,p_peer)` (idempotent; cursor = graph.entry|first node). Writes go via server ins/upd/del (match M1).
**Engine `ultron-wa/flow.js` (NEW, mirrors pacer/autoreply; runs in the supervisor):** a ~30s tick advances
runs whose `wait_until` elapsed (bounded instant-node hops per run per tick) AND `flow.consider({pool,
accountId,peerNumber,canonical,body})` — a WORKER-side, DB-ONLY hook placed RIGHT NEXT TO
`autoreply.consider` in worker.js (never replaces it) — flags a lead's `wait_reply` run resumable on reply
(supervisor tick then sends). The tick also attaches campaign replies to the campaign's flow. **Node types:**
`send` (spintax+{name}/{company}/{city}) · `send_file` (media_key→signed crm-files URL via doSend) · `wait`
(delay_minutes) · `wait_reply` (timeout_hours → reply|timeout branch) · `ai` (DeepSeek `deepseek-v4-flash`;
context = client `kb_doc` + node `master_prompt` + live lead CRM row + recent thread; per-node `permissions`;
human 30s–5min delay, business hours only) · `condition` (replied|keyword|var → yes|no branch) · `set_stage`
(calls `set_lead_stage`) · `handoff` (status='handoff' + task for the owner). Reuses autoreply's discipline:
Riyadh 09–21 window, per-account (`WA_FLOW_ACCOUNT_CAP` 60) + global (`WA_FLOW_GLOBAL_CAP` 300) daily caps,
opt-out (`auto_reply_last_ts='infinity'` + stop-words → run 'stopped'). Kill switch **`WA_FLOW_KILL=1`**.
supervisor.js `require('./flow')` + `flow.start({pool,workers,sendToWorker,log})` after boot (beside
pacer/warmup). **Dashboard `/flows` (`flows.html`, nav key `flows`, `workflow` icon, mob:true):** list of
flow cards → a framework-free builder (vanilla JS + SVG, NO React/build step) = palette + draggable node boxes
on a canvas (x/y stored in graph) + SVG connector lines + an inspector that edits each node's config AND wires
its next-node/branch selectors (the form editor + visual canvas share the one JSON engine); plus a per-client
knowledge-base editor and a flow-runs monitor; one-click **From template** seeds the real funnel (opener →
wait_reply → AI qualify → send_file portfolio → condition interested → set_stage "Meeting / Quote" → handoff).
campaigns.html editor gained an optional "Flow to run on reply" selector (sets `wa_campaigns.flow_id`). Server
routes: GET `/api/flows` `/api/flow` `/api/flow-runs` `/api/client-kb`; POST `/api/flow/save` `/api/flow/activate`
`/api/flow/delete` `/api/client/kb` (client-team-scoped; kb is admin-only). **IMPORTANT:** apply schema with
`ultron-wa` STOPPED (`systemctl stop ultron-wa`) to avoid the advisory-lock deadlock hit on M1, then restart.

### WhatsApp outreach — v2 revisions + MCP (2026-07-27) — SUPERSEDES parts of M1/M2 above
- **Campaign now = client + numbers + lead list + schedule/jitter ONLY; the FLOW owns ALL messages** (the
  campaign message_template/media inputs were REMOVED from the editor; columns kept but deprecated/unwritten).
  A campaign REQUIRES a `flow_id`; `/api/campaign/start` blocks without one. The pacer calls
  **`flow.firstTouch({accountId,flowId,placeId,peer})`** per due recipient → `start_flow_run` + sends the flow's
  **entry node** as the first *paced* message (pacer still owns window/cap/jitter/rotation); the flow engine
  drives everything after.
- **New `ai_router` node:** DeepSeek reads the incoming reply + a list of `{label,description}` branches and
  picks one (JSON output, temp 0, robust parse → `fallback` branch on miss). Replaces having to hand-write a
  `condition` for every possible reply; both node types coexist. Template now routes via ai_router.
- **Test-flow:** builder **Test** button stores `graph.meta.test_number` (per-flow, no schema change) and fires
  an INSTANT test run to that number, bypassing campaign pacing. Path: dashboard `POST /api/flow/test` →
  supervisor `POST /flow/test` → `flow.testRun({flowId,phone})`. Test runs waive business-hours/caps/opt-out/
  delays and run even for DRAFT flows (still honor `WA_FLOW_KILL` + need a connected number).
- **Flow builder rewrite (n8n-style):** infinite pan/zoom canvas, drag-to-connect edges (drag an output port),
  nodes with ports + colored accents, inspector-on-select (the empty "select a node" box is GONE), grouped
  add-node palette, tidy toolbar. Graph JSON schema unchanged (backward compatible).
- **Nav restructure:** channel-grouped collapsible sidebar — `NAVITEMS` items can carry `children:[]`; a
  **WhatsApp** parent expands to Messenger/Campaigns/Flows/Numbers (Email group stub ready). `U.nav('<child>')`
  still resolves + highlights. Global animated loader (`U.spinner`, `.uspin`, top progress bar in `U.api`)
  replaced ALL plain-text "Loading…". **Auto-reply toggles REMOVED** from `connections.html` + `whatsapp.html`
  (flows own replies now; the `auto_reply` column + `/api/wa/autoreply` + `autoreply.js` are left intact).
- **Per-number sharing/permissions:** `public.wa_account_shares(account_id,user_id,role viewer|manager,
  created_by, PK(account_id,user_id))`. `get_wa_accounts` returns owned + shared numbers with an effective
  `role` (owner|manager|viewer; admin=owner-equiv). Write actions (send/campaign/warmup/link) require
  owner/manager; viewers read-only. Invite UI on `/connections`; routes POST `/api/wa/share` `/api/wa/unshare`,
  GET `/api/wa/shares` `/api/wa/users`.
- **MCP server `ultron-wa-mcp/`** (NEW dir): a stdio Node MCP (`@modelcontextprotocol/sdk`) exposing **29 tools**
  for full WhatsApp control from Claude — flows CRUD + test_flow, campaigns CRUD + start/pause + recipients,
  read threads/conversations, number status (DB merged w/ supervisor `/status`), access mgmt (share/unshare),
  warmup get/set/delete, send_message. Talks to Supabase via a pooler-aware `pg` pool + the supervisor control
  API (`WA_CTRL_URL`+`WA_CTRL_TOKEN`), reading the repo `.env` (config loads `./.env` then `../.env`); defaults
  to admin/see-all, `MCP_USER_ID` scopes to an app_user. Register: `claude mcp add ultron-wa --scope user --
  node <path>/ultron-wa-mcp/index.js` (README has Mac + VPS). Mac `.env` got `WA_CTRL_URL=http://72.61.190.110:3100`
  + `WA_CTRL_TOKEN` for live status/sends. Railway builds ONLY `dashboard/`, so ultron-wa-mcp runs on the Mac/VPS,
  not Railway.
- **DEPLOY method (CRITICAL — this working tree has TWO git remotes):** `origin` = Ultron
  (git@github.com:AhmadKhalidSA/ultron.git, Railway deploys `dashboard/` from its `master`), `jarvis` = Jarvis
  (the `claude-voice/` subtree). The checkout is branch **`jarvis-v2`** which has DIVERGED from `origin/master` —
  NEVER force-push it to master. To ship Ultron→Railway: `git worktree add -b tmp <path> origin/master`, copy
  only the changed `dashboard/*` (+ `db/schema.sql` when schema changed, applied via `db-setup.js`), verify no
  `claude-voice`/secrets staged, commit, **fast-forward** `git push origin tmp:master`, remove the worktree.
  `ultron-wa/` + `ultron-wa-mcp/` deploy to the VPS via **rsync** (not Railway). WhatsApp auth dirs +
  `personal.json` are gitignored (were exposed until 2026-07-27).

### WhatsApp — AI Agent node + hosted MCP + connection fixes (2026-07-27, later)
- **`ai_agent` node** (flow.js) — a multi-turn, TOOL-using agent that OWNS the conversation (vs `ai` = one reply,
  `ai_router` = branch pick). The run STAYS on the node across replies until convert/end/handoff. Tools:
  `send_message`, `send_file` (per-node file library uploaded via `/api/upload`→crm-files), `set_stage`
  (allow-list), `schedule_followup` (self-nudge via wait_until), `create_task`/`book_demo`, `handoff_to_human`
  (status handoff + task; **`TODO(handoff-notify)` hook** for a future Discord/Telegram real-time ping — NOT built),
  `end_conversation` (won|lost|nurture → converted/ended edge). DeepSeek function-calling; safeguards
  `WA_FLOW_AGENT_MAX_TURNS`(40)/`AGENT_MAX_TOOL_ITERS`(6)/nudges(2). Builder: AI group, config = goal/persona +
  notes + file library + tool toggles + stage allow-list + outcome edges (converted/handoff/ended). Second
  "AI Agent" flow template added.
- **Hosted shareable MCP** — the dashboard mounts an Ultron MCP over **Streamable HTTP at `/mcp/:token`**
  (server.js, `@modelcontextprotocol/sdk`, stateless per-request). Token = `app_users.mcp_token` (schema §23,
  revocable). Link = `<dashboard-origin>/mcp/<token>` (Railway HTTPS). Tools = the 29 from `ultron-wa-mcp/`
  reimplemented on the dashboard's PostgREST `rpc()`/`mdb` + `waCtl()` layer, **scoped per-user** (admin=see-all,
  agent=own numbers/clients) — Ultron-ONLY, never Jarvis/personal. Extension point in `buildMcpToolTable` for
  future email/clients/pipelines/people/companies tools. **/connectors** page (Attio-style: personal link + copy
  + Regenerate/Revoke + add-to-Claude steps) + routes `/api/mcp/link|regenerate|revoke` + nav `connectors` (plug).
  The stdio `ultron-wa-mcp/` (pg-based) stays for local/VPS use; this hosted one is for teammates. Teammate adds it:
  `claude mcp add --transport http ultron <link>` OR claude.ai → custom connector → paste link.
- **Connection reliability** — fixed the "stuck on waiting-for-scan" bug: the Mac `link-bridge.js` holds the row
  at `status='linking'` for ~2min AFTER a successful scan (it captures full history sync first), so the dashboard
  never saw the connect. Fix: link-bridge clears `link_qr` + sets a `linked — finishing sync` marker instantly on
  `connection:open`; the QR poll now shows "Linked! bringing online…" and follows linking→disconnected→connected,
  toasts + closes on connect (90s safety fallback). Plain-English status/error mapping (**428 = dropped session,
  explicitly NOT a ban**; loggedOut = re-link). New actions: **Reconnect** (supervisor `/reconnect` re-forks from
  saved session, no QR), **Re-link** (fresh QR), **Refresh** (`/api/wa/live-status` proxies supervisor `/status`
  so a DB-'connected' row whose worker is stopped shows "Reconnecting…"). Auto-reply toggles removed earlier.

### WhatsApp outreach v3 — AGENT BRAIN FIXES + inbound catch-all + LISTS + funnel KPIs (2026-07-29)
The "make it one product" pass: leads → lists → campaign → flow → conversation → funnel numbers.
- **"Dumb agent" ROOT CAUSES (both verified live, never regress):** (1) **`deepseek-v4-flash` is a
  REASONING model** — hidden `reasoning_content` consumes `max_tokens` BEFORE the visible reply, so the
  old budgets (60 router / 260 reply / 700 agent) returned EMPTY content. flow.js now uses TOK_ROUTE 600 /
  TOK_REPLY 1200 / TOK_AGENT 1600 — never tighten them. (2) **LID split-thread blindness**: one human peer
  is often TWO `wa.threads` rows (phone-keyed = our flow sends; LID-keyed = their replies). flow.js history
  read only `peer_number = run.peer_phone`, so the AI saw its own messages and NEVER the customer's —
  "how much are prices?" → generic greeting. `threadHistory`/`lastThreadMsg` now resolve every thread whose
  `peer_number` OR `peer_phone` matches, and `ensureLatestInbound()` guarantees the message being answered
  is the model's final user turn. Same LID bug fixed in `campaign_mark_replies` (replied-KPI undercount).
- **Conversation fast-path:** a genuine reply turn (`_resumed` + non-empty `last_inbound`; plain `ai` node:
  any non-empty `last_inbound`; decrypt-miss timer wakes count as replies too) SKIPS the 9–21 Riyadh window
  and arms `REPLY_DELAY_*` **15–45s (~30s)** instead of 30s–5min; `agentSend` honors the transient
  `run._conv`. Cold opens + plain timer nudges keep window + long delay. Tick default is now **15s**.
- **SEND PRIORITY (Ahmed's ranking, same day): replies > follow-ups > cold — enforced, not aspirational.**
  `reserveCap(accountId, kind)` gives each tier headroom on the shared daily caps (`CAP_TIER`: cold 60% ·
  followup 85% · reply 100%), so a heavy outreach day can never starve replies; the tick's ORDER BY also
  puts `_resumed` runs first. Kind mapping: conversation turns/steps = reply; timer nudges + no-inbound-yet
  funnel steps = followup; agent cold openers = cold (campaign first-touches stay in the pacer's own pool).
- **Inbound catch-all (the customer-service gap):** `wa_accounts.reply_flow_id` (schema §24; UI = "AI reply
  flow" select on /connections, owner/manager only; POST `/api/wa/reply-flow`). flow.js `spawnReplyFlow()`:
  an inbound with NO live run on that number spawns (or re-opens a done/stopped) run on that flow at its
  entry node. Guards: flow must be ACTIVE · thread not private/opted-out · no active/waiting/**handoff** run
  (handoff = a human owns it, never barge in) · peer must resolve via lead_phones (worker's storeMessage
  creates sparse `wa:<phone>` leads first, so it does). Re-open resets the agent turn counters.
- **Decrypt-miss bound ("sometimes never replies"):** worker.js `handleDecryptFailure` now ALSO pulls any
  WAITING run's `wait_until` forward to **~5min** and sets `context._decrypt_miss`; the agent then politely
  asks the lead to RESEND (treated as a reply turn: no window gate, fast delay, reply cap tier) instead of
  staying silent for the 48h nudge timer.
- **LISTS (Apollo-style, schema §24):** `lead_lists` + `lead_list_members` (PK list+place). RPCs:
  `get_lead_lists` · `list_add_members` · `list_add_from_filter` (a thin wrapper that CALLS `search_leads`
  with the same params — zero duplicated filter logic, list = exactly what the page showed) ·
  `campaign_add_from_list` (mobile-only/dedupe rules stay in `campaign_add_recipients`). UI: /leads got a
  checkbox column + select-all + "Select all N matching this filter" + an Add-to-list modal (create new or
  add to existing); /campaigns got "From a saved list" in the add-recipients picker. Routes: GET `/api/lists`;
  POST `/api/list/create|add|add-filter|archive|remove`, `/api/campaign/recipients-from-list`.
- **Funnel KPIs (schema §24 — the numbers clients ask weekly):** `campaign_funnel(p_id)` → queued/sent/
  replied/failed/opted_out/in_conversation/handoff/**meetings**/**won**/lost/nurture + a 30-day daily
  sent-vs-replied series (meetings/won come from REAL `lead_stage_history` moves after `sent_at` — stage
  name `meeting%` / `ps.kind='won'` — plus the ai_agent's `_agent_outcome`); `client_funnel(p_client,p_days)`
  = the same rolled up across a client's campaigns (the weekly report in one call). Routes: GET
  `/api/campaign/funnel`, `/api/client/funnel`. Campaign detail modal renders stat tiles + a div-based
  daily mini-chart (no chart lib).
- `get_wa_accounts` **v2** (redefined in §24, AFTER the reply_flow_id alter, overriding §15's) adds
  `reply_flow_id`/`reply_flow_name`/`reply_flow_status` for the /connections selector.

### WhatsApp v3.1 — one-stop editor, list view, agent-node clarity, ban alerts (2026-07-29, later)
- **ai_agent always closes in its own words:** system prompt now REQUIRES a natural send_message
  goodbye before end_conversation/handoff_to_human. The builder's outcome edges are therefore truly
  optional — /flows inspector retitled them "After the conversation (optional)" with hints ("— nothing,
  just end —" placeholder), plus plain-English hints on max_turns ("Max messages (safety limit)") and
  silence_hours ("Follow up after (hours)"). Never require stub send-nodes on outcome edges again.
- **/campaigns editor is one-stop:** a Leads section (saved-list select · debounced Ultron-lead search
  `q=&phone=mobile` · "+ New contact" inline form) + **Save draft / Save & Start** buttons. Save
  orchestration: /api/campaign/save → recipients-from-list → recipients(place_ids) → optional start.
  "+ New contact" posts the EXISTING `/api/lead/create` which now **dedupes by phone** via lead_phones
  (returns `existing:true` + the mapped place_id instead of minting a duplicate).
- **/leads List view:** a List dropdown (from `/api/lists`); picking one enters list mode — rows come
  from `get_list_leads` RPC via GET `/api/list-leads` (same {total,rows} shape as search_leads, contact
  cols nulled), other filters disabled, selection bar gains "Remove from list".
- **Ban/off-line resilience (Ahmed's "does the system KNOW?"):** routing already existed — the pacer
  claims recipients ONLY for connected workers (queued rows are unassigned until claim), so a dead
  number is routed around and nothing is lost; recipients requeue on soft-fail (attempts<3), 'sending'
  orphans reaped every tick, per-number daily cap is DB-computed (restart-safe). NEW: supervisor.js
  Telegram alerts via `require('../scripts/notify')` — instant on `logged_out` (ban/unlink), on 4
  consecutive crash-reforks, and when a RUNNING campaign's number is offline ~20min (10-min watch,
  2-strike, 2h cooldown/key). **GOTCHA FOUND & FIXED: `OPS_TELEGRAM_CHAT` had vanished from VPS
  `/root/ultron/.env` — ops-digest/ops-watch/memory-watch had been failing silently ("notify: missing
  bot token or OPS_TELEGRAM_CHAT" in their logs). Restored (=798935024, Ahmed's allowlisted chat).
  If notifies ever go quiet, check that var FIRST.**
- Campaign pacing reality-check (so nobody "fixes" it): sends_per_hour=8 → ONE first-touch per number
  every ~7.5min ±40% jitter inside the send window; 350 leads on one number at defaults (cap 40/day)
  ≈ 9 days. That's the designed anti-ban drip, not a stall.

### WhatsApp v3.2 — TRUE self-healing numbers + soft delete/restore + reply-brain moved to /flows (2026-07-29, evening)
- **ZOMBIE ROOT CAUSE (the "offline 20min while my phone says connected" incident):** supervisor's
  `RUNNABLE` was `['connected','disconnected']` — a worker dying mid-handshake (proxy 408; NOT a ban,
  and unrelated to the phone app's own connection) leaves the row at `'connecting'`, which the
  reconciler treated as not-runnable → NEVER re-forked → dead until a human hit Reconnect. Fixed:
  `'connecting'` added to RUNNABLE, + a **stall watchdog** in reconcile(): a live worker ≠connected
  for >8min (excl. 'linking') is force-recycled. Numbers now self-heal in ≤~8min worst case; the
  20-min campaign-number alert should only fire for REAL problems now. Do NOT auto-pause campaigns
  on a down number — the pacer already skips unconnected numbers and the queue simply waits/resumes
  (auto-pause would demand a manual restart = strictly worse).
- **Soft delete/restore for numbers (Ahmed's rule: NEVER delete messages):** `wa_accounts.deleted_at`
  (schema §24) — delete = hide + stop worker + detach from campaigns (+ **auto-pause a RUNNING campaign
  left with zero senders**, names returned + toasted) + clear reply_flow_id; wa.threads/wa.messages
  are UNTOUCHED. Restore (same row/uuid) brings history back intact; reconciler ignores deleted rows.
  `wa_account_impact(p_id)` RPC powers the confirm modal (threads/messages kept, live runs, campaigns
  w/ sole_sender warnings). Routes: GET `/api/wa/impact`, `/api/wa/deleted`; POST `/api/wa/delete`,
  `/api/wa/restore`. On connect, the supervisor notifies if the phone's history lives under a deleted
  twin entry (restore that one instead of splitting history). get_wa_accounts now filters deleted.
- **Reply-brain UI moved:** the per-number "AI reply flow" selector was REMOVED from /connections
  (Ahmed: wrong place). It now lives on /flows as an **"Inbound numbers"** panel per flow (checkbox
  per owned/managed number; shows which flow currently answers each number; warns when reassigning).
  Backend unchanged (`wa_accounts.reply_flow_id` + POST `/api/wa/reply-flow`).

### WhatsApp v3.3 — ONE SEND PER HUMAN + no self-loops + system-only warmup partners (2026-07-29, night)
Two live incidents, both root-caused from the DB, never regress either:
- **Double-messaging leads:** recipient dedupe was per (campaign_id, place_id), but the SAME HUMAN
  PHONE exists under many place_ids (one owner, many Maps places — one phone was queued **9×**).
  `campaign_add_recipients` v3 (§24, overrides §21+v2) picks `distinct on (phone)`, skips phones the
  campaign already holds in ANY status, and NEVER queues our own `wa_accounts` numbers; the pacer's
  `claimRecipient` enforces the same at claim time (belt). Pacer also gained a tick-overlap guard
  (`ticking`) + `wa_campaign_recipients.claimed_at` so the orphan-reaper only requeues claims >5min
  old (the old reap-on-every-tick could requeue a row that was legitimately mid-send). `firstTouch`
  skips an opener byte-identical to the last outbound (last-resort). One-off cleanup marked 27
  duplicate queued rows 'skipped' on the live campaign.
- **"Warmup spamming the sent message":** warmup chatter between Ahmed's own numbers hit the
  catch-all on the number whose `reply_flow_id` = the outreach flow → it fired the campaign OPENER
  at his own number in a loop. Fixed twice over: `spawnReplyFlow` (1) returns immediately when the
  peer is one of our own `wa_accounts` phones, (2) starts a (re)spawned run at the first `ai`/
  `ai_agent` node instead of replaying a send-node opener (an inbound deserves an ANSWER, not the
  pitch again). Warmup peers are canonicalized (norm_phone) at read AND save (old rows fixed).
- **Warmup partners = system numbers ONLY (Ahmed's rule):** new `get_warmup_partner_options()` RPC —
  every linked, non-deleted number in the system, ANY user's, no share/invite needed — feeds the
  /campaigns warmup partner picker (manual phone entry REMOVED; `/api/warmup/save` validates peers
  against system numbers + canonicalizes). `get_warmup` v2 + warmup.js exclude deleted numbers
  (deleted "k" was still listed). Route: GET `/api/warmup/partners`.

### WhatsApp v3.4 — plain-reply detection + run traces + honest toggles (2026-07-29, late night)
- **"Only quote-replies wake the agent" (live-tested by Ahmed):** a PLAIN inbound in a LID-addressed
  chat often carries NOTHING mapping LID→phone, so `consider()`'s canonical never matched the run's
  `peer_phone`; a QUOTE-reply carries `senderPn` and matched. Fix: consider() expands the peer set via
  `wa.threads` + `wa_lid_map` before matching (and backfills `canonical` for the catch-all). Nobody
  quote-replies in real life — never regress this.
- **Run TRACES (n8n-style in/out):** flow.js `trace(run,kind,note)` appends the last 12 steps to
  `context._trace` (jsonb_set, never blocks a send): send/ai/ai_router/condition/wait_reply resume +
  a per-turn ai_agent summary ("in: <msg> → msg:…/file:…/stage:…"). `get_flow_runs` returns `trace`;
  the /flows Runs monitor rows expand to show it.
- **Tool-toggle UX trap (cost Ahmed his PDF):** the highlighted permchips read backwards — he
  UNTICKED send_file thinking he was enabling it, so the agent literally had no file tool (that was
  the whole "file didn't send" bug — not DeepSeek). Toggles are now labeled CHECKBOXES; attaching a
  file AUTO-ENABLES send_file (toast). His flow's send_file+set_stage were flipped on via SQL.
- **Model choice (asked + answered):** `deepseek-v4-flash` = DeepSeek's fast tier (Sonnet-ish, ~3s
  incl. reasoning); `deepseek-v4-pro` is the Opus-class one — deliberately NOT used. Perceived
  "thinking time" is the intentional 15–45s human reply delay, not the model.

### WhatsApp v3.5 — agent full-control pass (2026-07-29, latest)
- **LIVE stages every turn:** runAgent overwrites `cfg.stages` from `liveStages(client_id)` (default
  pipeline, ordered) each turn — builder snapshots go stale when Ahmed edits a client's pipeline.
  Empty `allow_stage_ids` = agent may move to ANY current stage by context ("asked for price",
  "quotation sent", …). Outcome won/lost stage ids fall back to live stages by `kind`.
- **Sales playbook in the system prompt:** silence NEVER ends a conversation — schedule_followup
  (~48h) with fresh angles, up to ~3 reminders (AGENT_MAX_NUDGES default now 3); end only on a clear
  no / clear convert; unsure asks → "I'll check with the team" + handoff_to_human; keep CRM true via
  set_stage as the conversation progresses.
- **Handoff → Telegram (TODO(handoff-notify) BUILT):** flow.js requires `../scripts/notify` (safe —
  it runs in the supervisor); doHandoff + agentTerminal(handoff) ping Ahmed with the customer name/
  phone + WHICH of his numbers holds the chat. Test runs never ping.
- **Traces carry node ids** ({t,k,n,id}); builder inspector got an **Activity** section per node
  (that node's last ~8 in/out lines across runs, lazy-cached, ⟳ refresh) — the n8n-style per-node
  view. Stage restriction UI = labeled checkboxes ("unchecked = any live stage").
- **get_campaign v2 (§24, overrides §21):** each recipient carries `run_status`/`run_node`/
  `last_trace`; campaigns.html shows a flow-progress chip (in flow / needs human / flow done /
  stopped; 'blocked|not sent|error' or stopped → danger) + a one-line last-step under the name —
  "where exactly did this lead stop". Full traces stay in /flows Runs. MCP note: flow-runs traces
  flow through the existing get_flow_runs RPC, so MCP-connected AIs can answer "why did this lead
  fail" with zero new tools.

## WhatsApp CRM layer (2026-07-16 — full plan + status in `CRM-PLAN.md`, READ IT before touching this)
Ultron is the single source of truth for conversations; jarvis-memory keeps only summaries. Schema §12:
`public.norm_phone()` (canonical digits, Saudi → 9665…), `public.lead_phones` (phone PK → place_id, many
per lead — THE WhatsApp↔Ultron join key, backfilled from `leads.phone`/`alt_phones`), and a **separate
Postgres schema `wa`** (deliberately not PostgREST-exposed): `wa.threads` (peer, resolved place_id,
`visibility` team|private), `wa.messages` (verbatim bodies incl. `[voice note] …` transcripts),
`wa.media` (mirror of the reader's media table; binaries in private bucket `wa-media`). Dashboard reads
via public RPCs `get_lead_conversation`/`get_wa_media` only; lead modal has a WhatsApp panel; server
routes `/api/lead-conversation` + `/api/wa-media` (signed 1h URL). Sync: **hourly launchd job on Ahmed's
Mac** `claude-voice/whatsapp-reader/ultron_sync.js` (`com.jarvis.wa-ultron-sync.plist`) mirrors
whatsapp.db → Supabase, resolves numbers via `lead_phones`, and **creates sparse leads**
(`place_id='wa:'+phone`, `source='WhatsApp'`) for unknown numbers — INSERT-only, never updates scraper
data. Privacy: numbers in `claude-voice/whatsapp-reader/personal.json` never sync and their threads are
forced private. Phases 3-5 (follow-up scanner, source-aware filters, media hardening) are planned in
CRM-PLAN.md, not built.

## Common commands
```bash
# deploy local → VPS
rsync -az --exclude node_modules --exclude .git --exclude data -e ssh ./ ultron:/root/ultron/
# apply DB schema/RPC changes
ssh ultron 'cd /root/ultron && node scripts/db-setup.js'
# run a city (Phase 1)
ssh ultron 'cd /root/ultron && node src/run-grid.js "coffee shop" "Al Khobar"'
# restart dashboard
ssh ultron 'systemctl restart ultron-dashboard'
# push to GitHub (Railway auto-deploys)
git push origin master
```
VPS crons: `heartbeat.js` (1min), `verify-run.js` (30min), `enrich-web.js` (hourly :37), `enrich-reviews.js` (2h),
`plan-manager.js` (15min), `ops-watch.js` (5min), `ops-digest.js` (daily 04:00 UTC),
`memory-backup.js` (daily 03:40 UTC), `memory-watch.js` (30min),
`agent-watchdog.sh` (1min) + `@reboot agent-launch.sh` (Telegram agent, see below).

## Telegram agent (Ahmed's phone → Claude Code on the VPS)
A 24/7 Claude Code session runs on the VPS in tmux session **`agent`** (cwd `/root/ultron`), bridged to
Telegram via the official **channels** plugin (`telegram@claude-plugins-official`). Ahmed messages
**@Ultronfuckassbot** (text or voice notes); only his Telegram ID (allowlisted, `798935024`) gets through.
Runs on his **claude.ai Max subscription** (creds `/root/.claude/.credentials.json` — copied from the Mac
Keychain; NO API billing) with `--dangerously-skip-permissions` (full autonomy, explicitly chosen).
- **Pieces** (all on VPS): `/root/tools/agent-launch.sh` (starts tmux+claude; model read from
  `/root/tools/.agent-model`, default `sonnet`) · `/root/tools/agent-watchdog.sh` (cron 1min: if session
  died → Telegram alert via bot API + relaunch) · `/root/tools/transcribe.sh` (voice→text:
  ffmpeg + whisper.cpp `small` model, ~30s per 20s note on the 2-core box; `MODEL=large-v3-turbo-q5_0` env
  for max Arabic accuracy) · whisper.cpp + models in `/root/tools/whisper.cpp/`.
- **Agent behavior rules** live in `/root/.claude/CLAUDE.md` on the VPS (👀/👍 reactions, voice-note
  transcription flow, progress pings, never paste .env secrets, model-switch-by-suicide: writes
  `.agent-model` then kills its own tmux session; watchdog revives it on the new model in <60s).
- **Bot token**: `/root/.claude/channels/telegram/.env`; allowlist state `/root/.claude/channels/telegram/access.json`.
- **Restart agent**: `tmux kill-session -t agent` (watchdog relaunches) · attach: `tmux attach -t agent`.
- Don't call Telegram `getUpdates` manually — it races the plugin's long-poll.

## Gotchas / lessons learned
- **`pkill -f run-grid.js` kills your own ssh session** (self-match). Use `ps aux | grep "[r]un-grid" | awk '{print $2}' | xargs -r kill -9`.
- Google place URLs often **drop `@lat,lng`** — always also parse `!3d!4d`.
- Google **viewport search spills distant popular results** → bbox-filter by city; derive `city` from GPS.
- Maps infinite-scroll needs **real `page.mouse.wheel()`**, not `element.scrollTo()`.
- Port **25 is open** on this VPS → SMTP email verification works (unusual; most VPS block it).
- Harness blocks **foreground `sleep`**; use background tasks / until-loops.
- Upsert is **COALESCE** — re-scraping fills gaps and refreshes `last_seen`, never wipes data.
- **Google Maps has no email/socials** — those only exist on the business's own website (or its Instagram),
  so the Phase-3 website crawl is inherent, not removable. Many Saudi F&B "websites" ARE Instagram/wa.me links.
- **Grid tile keys must match format** (`(+lat).toFixed(4)`) between the ledger and the computed tile, or
  tile-skip silently never matches and re-scans everything.
- **`kill -9` of run-grid ORPHANS its Chrome tree** (~13 procs each) → repeated kills piled up 62 orphan
  Chromes and exhausted VPS RAM, wedging the scraper. Mitigated: run-grid kills its browser on
  SIGTERM/exit/crash; run-plan runs children in process groups and group-kills. To clean orphans:
  `kill -9 $(pgrep -f chrome)`. Prefer SIGTERM over SIGKILL on run-grid so it cleans up Chrome itself.
- **Zoom doesn't shrink Google's result radius** in dense areas — a 140m tile still returns ~120. So
  subdivision past ~1 level finds nothing new; base-grid density + self-terminating subdivision is the fix.
- Embedded FB/Meta SDKs leak junk handles like `instagram.com/rsrc.php` into page HTML — `cleanSocial`
  rejects filename-like handles (`.php/.js/...`); a one-off backfill already nulled ~97 legacy junk rows.
- **Supabase direct DB host is now IPv6-ONLY.** `db.<ref>.supabase.co` has no A record anymore (only AAAA),
  so any pg client on an IPv4-only network (Ahmed's Mac home wifi) fails `getaddrinfo ENOTFOUND` — silently
  wedged the WhatsApp link-bridge and the Mac's `ultron_sync.js` (2026-07-20). Fix = route through Supabase's
  **IPv4 Supavisor pooler**: host `aws-1-ap-southeast-2.pooler.supabase.com` (this project is in **ap-southeast-2/
  Sydney** — derived from the direct host's IPv6 vs AWS ip-ranges), port 5432 (session mode), user **`postgres.<ref>`**
  (not plain `postgres`). Note the `aws-1-` prefix — `aws-0-*` returns "Tenant or user not found". Wired as an
  opt-in env var `SUPABASE_POOLER_HOST` in the **Mac** root `.env`: when set, `link-bridge.js`/`pair-local.js`/
  `pool.js`/`ultron_sync.js` use the pooler; unset (the VPS, which has IPv6) → they keep the direct host. The REST
  API host (`<ref>.supabase.co`, Cloudflare) is unaffected — it's always IPv4. VPS scraper (`src/supabase.js`) and
  `scripts/*` still use direct and are fine as long as the box has IPv6.

## Roadmap / TODO
**North star: Ultron = the owner's full marketing-ops hand** — scrape → enrich (multi-source profile per
company) → verify → outreach (WhatsApp + email via Smartlead) → track replies. Key Saudi insight from the
owner: business phone numbers often live in the **Instagram bio**, not on Maps.
- **Enrichers** (reuse the Phase-2 pattern: cron picks N stale leads → fetch → COALESCE upsert; store raw
  per-source JSONB + promote hot fields): 1) **Instagram** (bio phone, wa.me link, followers, last-post
  activity — top priority) 2) TikTok profile 3) Google-search fallback for leads without a website
  4) LinkedIn **company pages only** (branches/size — skip personal employee data: legal + PDPL risk).
- **WhatsApp outreach**: dedicated/aged number (never the main one), QR connect surfaced on the Ops page,
  hard daily caps + randomized human pacing + opt-out ("إيقاف") suppression list. Official Cloud API is the
  ban-proof route; whatsapp-web.js (puppeteer, already on VPS) is the free unofficial one.
- **Smartlead**: push verified-email segments into campaigns via their API, webhook opens/replies back into
  an `outreach_events` table; emails are already warming in the owner's Smartlead account.
- Lead lifecycle: `status` pipeline (new→enriched→verified→queued→contacted→replied) + `touches` log table.
- Still open: searchable multi-select filter · GB-usage meter on dashboard (+ in ops-digest) · PTR/SPF for the
  verifier · block/captcha detection + backoff · Chao1 coverage gauge + smarter frontier scheduler (plan-manager
  is the basic version: refill+requeue; coverage-driven job *scoring* still open) · WhatsApp reader x3 numbers
  via Baileys (Ahmed approved concept 2026-07-04; low-volume replies only, ban-risk numbers) · Smartlead
  integration (Ahmed to provide API key + client domain/mailboxes) · Jarvis-TV compile when scraper idle.
