Early-Stage Opportunity Scan — Spec
Drafted 2026-05-24. Status: PROPOSAL — awaiting greenlight + decisions (§9).
0. TL;DR
The advisor's "Early Stage Website Deals to Accelerate" analysis was wrong at the data-model level: it read amount / deal_source / what_products_pitched / forecast_category on Early Stage deals as if they proved a sales conversation. At stage_group = "Early Stage" (Opportunity / Reached Out / Planned / Appointment Made) FCR has not had a qualifying conversation — those fields are deal-creation artifacts (source templates, portfolio placeholders, carried-over numbers), not intent.
Fix is two parts:
- Part A — Guardrail (ship first, ~1hr). A hard rule in the advisor addendum +
CLAUDE.mdso no surface (Roam advisor, HubSpot card,/prospect,/deals) narrates early-stage deals as "they engaged / audit already run / conversation stalled". Root-cause fix. - Part B — The scan (the build). A read-only capability that identifies opportunity on early-stage deals from facts we already precomputed (the deal brief) cross-referenced with deal history (deal patterns), and returns a ranked next-best-product list per AM with evidence and a comparator. It's an orchestration layer over two assets that already exist — not new data infra.
It is not a marketing skill. Marketing skills build targeting lists and write to marketing's own BQ/HubSpot via n8n (governed write surface). This is sales/AM read+analysis. marketing-list / upgrade-outreach / email-templates can consume its output later; the scan itself never writes.
1. Why the inputs already exist
| Input | Asset | Status |
|---|---|---|
| Preloaded prospect facts | Deal brief — deal-brief:<deal_id> KV (v1.3.0, 35d TTL), read via dashboard-get-deal-brief / get_deal_brief tool |
LIVE. Composed for all open deals, early-stage included. ~30ms warm read. |
| Deal history | Deal patterns — dashboard-deal-patterns / extract_deal_patterns tool, Vectorize fcr-deal-history, closed-only default |
LIVE. Win/loss themes + cohort by service_trade/county/outcome. |
The brief already carries everything the scan needs to assess a prospect:
deal—stage_group,most_recent_stage,deal_source,service_trade,county,owner_name,subscriber_id,classifications,opportunity_type,amount.website— live home-page crawl:pageCount,contentPageCount,features(has_booking,has_ecommerce,has_blog,has_gallery,has_chat,has_forms,has_phone,detected_cms),title,metaDescription.nullwhen no CRM website URL → greenfield signal.gbp_live+enrichment.gbp— claimed?, rating, review count, response rate, services, hours, photos.prospect_intel— category keywords, local rank, similar FCR sites, local-area competitor density.enrichment— ads presence, GSC, Ahrefs DR.gmb_match(read-path) — confidence the GBP we matched is actually this business.
So the brief is the raw material, already sitting in KV for exactly the early-stage cohort, at near-zero read cost.
2. Part A — The guardrail (root cause)
Two placements, both needed:
- Global advisor hard rule — in
buildSystemAddendum(...)(worker/src/handlers/ai-advisor.js~line 3708, alongside theWRITING STYLE (NON-NEGOTIABLE)em-dash block). The addendum is concatenated onto every advisor invocation (Roam + HubSpot card), so this is the only spot that fixes both surfaces. ⚠️ No inline backticks and no\<char>escapes inside that template literal (known traps — see memoryaddendum-no-backticks,template-literal-escapes). - Project data rule — a new bullet under Data Rules in
CLAUDE.md, so Claude Code skills (/deals,/prospect,/marketing-list) inherit it too.
Proposed rule text (plain, no backticks):
EARLY-STAGE DEALS = NOT YET CONTACTED. At stage_group "Early Stage" (Opportunity, Reached Out, Planned, Appointment Made) FCR has not had a qualifying conversation. amount, deal_source, what_products_pitched and forecast_category at this stage are deal-creation artifacts — source templates, portfolio placeholders, carried-over numbers — NOT evidence of intent, a pitch, or a stalled conversation. Never narrate an early-stage deal as "they engaged", "audit already run", "conversation stalled", or assign a product the prospect supposedly asked for. To assess opportunity on an early-stage deal, derive it from the prospect's facts (deal brief: website / GBP / keywords / competitors / footprint) cross-referenced with deal-history patterns — never from the deal's own pitch/value/source fields.
Also add one line to .claude/skills/deals/SKILL.md Mode 1/6 notes pointing at the same rule.
3. Part B — The scan pipeline
A single new worker route dashboard-early-stage-opportunities (so the advisor tool and the /deals skill share one code path). Steps:
Step 1 — Cohort select (one BQ query on hubspot_deals)
WHERE stage_group = "Early Stage".- Strip auto-created junk (not "low win rate" sources — we derive from facts, so source's only job is to drop non-real rows):
deal_source NOT LIKE "%NOF%", and excludeCOMP Site(no Company → blank fields),General,TOV%/Post TOV(discontinued grant route), pure listing-request rows (Step 1 Listing Request,GetLocal Listing Request,GetLocal DNQ). Require a resolvable prospect:subscriber_id IS NOT NULL OR company_name IS NOT NULL. - Optional filters:
owner(per-AM),service_trade,county,limit. - Branch existing vs net-new: LEFT JOIN
fcr_operations.active_clients(latest snapshot) onsubscriber_id.subscriber_idpopulated ≠ active client (memorysubscriber-id-meaning) — must confirm via join.- Existing client → upgrade play (reuse
/upgrade-outreachgap logic). - Net-new → greenfield digital-launch (memory
invisible-newbiz-is-the-pitch: invisibility IS the pitch).
- Existing client → upgrade play (reuse
Step 2 — Read briefs (KV, batched)
For each deal, internal("dashboard-get-deal-brief", { deal_id }, env). Already-precomputed → ~30ms each. A never-composed early-stage deal triggers a one-off compose (5–20s); rare, since the cron covers all open deals. Extract the FACTS listed in §1.
Step 3 — Facts → candidate product (rules table, lib/opportunity-rules.js)
Explicit, auditable, editable by the product team. Starter mapping (needs sign-off — §9.2):
| Signal in brief | Candidate next-best product |
|---|---|
website null / no site |
SitePro or Storefront (website build) |
1-page site (contentPageCount ≤ 1, detected_cms = SitePro) |
SitePro multi-page upgrade |
| GBP unclaimed / low rating / few reviews / low response rate | GBP optimisation + SayMore (reviews/reputation — note: review requests are MANUAL, memory saymore-review-requests-manual) |
| Has site but outranked on category keywords (prospect_intel local rank) | SEO / LocalRank Check |
Competitive category, no paid presence (enrichment ads empty) |
Google Ads / SEA |
Product/ecommerce business (has_ecommerce) |
Merchant Centre / GetLocal CSS |
Multiple signals → multiple candidates, ranked.
Step 4 — Ground against deal history
Per distinct service_trade in the cohort (NOT per deal — deal-patterns runs Claude; batch to bound cost), call dashboard-deal-patterns (closed-only) and cache the result in KV ~30d. Use it to: (a) confirm the candidate product is one this profile actually buys, (b) attach historical win-rate / typical first product / typical value, (c) attach a comparator ("4 similar roofers in Cork closed on SitePro+SEO").
Step 5 — Score & rank
opportunity_score = gap_severity × historical_propensity × reachability
gap_severity— how absent/weak the facts are (no site > weak GBP > outranked).historical_propensity— win-rate of this profile→product from Step 4.reachability— has owner + contact route.
Step 6 — Output (read-only)
Per deal: company, owner, stage, the evidence (facts), recommended product(s), the history comparator, and a suggested opening angle grounded in facts — never "you engaged". Optionally publish:true → HTML share page at /early-stage-opportunities/{slug} (mirror the /deal-patterns/{slug} + /proposal/{slug} pattern), and/or DM the owning rep via the existing /dashboard-roam-dm path (memory prospect-snap-extension).
4. Surfaces
| Surface | Change |
|---|---|
| Worker route | New dashboard-early-stage-opportunities (register pattern, in-process internal() calls to get-deal-brief + deal-patterns). Add to SKILL_ROUTES in lib/usage-log.js for telemetry. |
| Advisor tool | New scan_early_stage_opportunities in the TOOLS array + execTool branch (mirror extract_deal_patterns: worker/src/handlers/ai-advisor.js:161 def, :2441 dispatch). Add a TOOL_LABELS entry (~line 64). |
/deals skill |
New Mode 6 (--opportunities / --scan [--owner X]) on the existing 5-mode thin router, OR a sibling /opportunity-scan skill. (Decision §9.1.) |
| Rules lib | New worker/src/lib/opportunity-rules.js — the facts→product table. |
| Guardrail | buildSystemAddendum + CLAUDE.md (Part A). |
5. Cost posture (per CLAUDE.md BigQuery rule)
- Cohort select = one query on the clustered open-deals table (
hubspot_deals), not per-row. - Facts = KV reads of already-precomputed briefs (no live fan-out per deal).
- Claude usage bounded by per-distinct-service_trade deal-patterns calls (cached 30d), not per-deal.
- No per-subscriber raw-feed scans. Safe under
BQ_MAX_BYTES_BILLED.
6. What this explicitly does NOT do (v1)
- No writes — no deal notes, no task creation, no HubSpot list, no marketing-list append. Read/analysis only.
- No re-derivation of the Early/Mid/Late boundary — read
stage_groupas-is (HubSpot-owned). - No new precompute cron in v1 (on-demand). A nightly precompute can come later if §9.2 lands well.
7. Build order
- Part A guardrail (addendum + CLAUDE.md + SKILL.md line). Ship + smoke-test on the exact deals the advisor mis-narrated.
lib/opportunity-rules.jsstarter table.dashboard-early-stage-opportunitiesroute (cohort → briefs → rules → patterns → score). Smoke-test per-AM.scan_early_stage_opportunitiesadvisor tool + label + SKILL_ROUTES./dealsMode 6 (or/opportunity-scan).- (Optional) HTML publish + Roam DM.
8. Risks / watch-items
- Product-mapping quality is the whole game (§3 table). A wrong rule produces confident-but-wrong recommendations — same failure class as the original advisor output, just relocated. Needs product-team sign-off and should be easy to edit (hence a lib, not inline).
- Brief freshness for early-stage net-new —
gbp_live/websiteare live-fetched at compose; confirm coverage isn't thin for no-subscriber prospects. gmb_matchconfidence — when low, flag the GBP facts as unverified rather than asserting them.- Don't reintroduce source as a signal anywhere except the junk-strip in Step 1.
9. Decisions — LOCKED 2026-05-24
- Skill home — ✅ Mode 6 on
/deals(--opportunities/--scan [--owner X]). - v1 trigger/scope — ✅ On-demand per-AM. No new cron in v1; nightly precompute deferred until the rules table is trusted.
- Output surface — ✅ Chat/markdown only for v1. HTML share page + Roam DM deferred.
- Rules-table sign-off — ✅ Cathal reviews the §3 facts→product table directly. Ship as an editable lib; he edits/approves before Part B goes live.
10. v2 direction — competitive-relative fit (added 2026-05-24, BUILD PAUSED)
Cathal's input after reviewing v1: the recommendation should not be an absolute ideal, it should be competitive-relative — "you need to be better than your competitors, not the best, to win." v1 (worker/src/lib/opportunity-rules.js, 16 rules) stands as the absolute baseline; v2 layers a benchmark on top:
recommend = ladder_step_that_clears( benchmark(class, geo_tier) ) − already_owned
- class = normalized category; geo_tier = local vs metro (grid-map-build.js metros/SAB radii + county/locality).
- benchmark(class, geo_tier) = avg/median digital footprint of the dominant set (top LRC/3-pack rankers, top GBP prominence, top organic) in that class+geo: has-site / page-count / blog / GBP reviews+rating / directories / ads. Buildable from CATEGORY_BENCHMARKS, KEYWORD_INTELLIGENCE, prospect_intel local-area, competitor enrichment, LRC/grid top-rankers, per-category gold-standard clients.
- Entry tier gap: BizSite (1-page site + SayMore tech, sites on bizsite.ie) is NOT in
fcr-products.js, so the ladder jumps to SitePro Multi. Add it (canonical spec/pricing + sales-ops PR) so e.g. home-services-local resolves to BizSite, not an over-spec'd SitePro. - Ads gating: a website that clears the class bar is a prerequisite before SEA/Optimiser/LSA (Google weighs the site to judge ad-appropriateness — per a Google employee interview). Ladder enforces website-first.
- Seed rules-of-thumb (validate w/ sales+product): home-services local → BizSite; home-services metro → multi-page SitePro sized to service range + locality; personal development → multi-page + blog/content.
Next step (not now): Cathal takes v1 + this model to sales + product. Build the benchmark + route only after their input and the BizSite catalogue entry land. Do not build until greenlit.
FCR Dashboard documentation · generated from docs/ · keep counts verified, not guessed.