Skip to content

Architecture Overview

Plain-English walkthrough of the FCR Dashboard platform: what it is, the pieces it’s built from, how they fit together, and why it’s fast. Pair this with fcr-dashboard-architecture-deck.md (the slide storyboard) to build a presentation, and hubspot-data-architecture.md for the data-layer detail.

Written 2026-05-20; figures verified live 2026-06-09.


The FCR Dashboard is an internal sales & account-management intelligence platform. It pulls FCR’s data — CRM, billing, HubSpot deals, Google Business Profiles, ads, search, keyword intelligence, census/area data — into one place and puts an AI advisor on top of it, so a rep or AM can ask a plain-English question about any account or prospect and get a complete, sourced answer in seconds.

It shows up in three places a rep already works:

  • a web dashboard (portfolio, accounts, maps, reports),
  • the HubSpot deal sidebar (the Prospect Advisor card),
  • Roam chat (the same advisor in the team’s chat tool), and
  • Claude Code skills (/check-account, /prospect, … — for power users in Claude Code / Claude for Work).

All four are powered by the same brain.


2. The four surfaces (what the user touches)

Section titled “2. The four surfaces (what the user touches)”
SurfaceWhat it isRuns on
Web UIReact dashboard — portfolio, account lookups, area maps, reportsCloudflare Pages (fcr-dashboard-ui.pages.dev)
HubSpot card”Prospect Advisor” chat in the deal sidebarHubSpot’s UI-extension sandbox
Roam advisorSame advisor, in Roam chatRoam, via webhook
Claude Code skillsSlash-command workflows (/check-account, /prospect, /deep-dive, /portfolio, …)Claude Code / Claude for Work (CLI / desktop agent) — not the web UI

Every one of them is a client — they all call the same back-end Worker API.

Claude Code skills (a note on what these are)

Section titled “Claude Code skills (a note on what these are)”

The skills are an FCR-specific layer that runs only inside Claude Code / Claude for Work — they are not part of the web app, the worker, or the HubSpot card. Each is a SKILL.md (in .claude/skills/ and .agents/skills/) that tells the agent how to orchestrate the Worker’s endpoints for a task, e.g.:

  • Account / portfolio: /check-account, /portfolio, /deep-dive, /am-review, /deals, /tickets, /revenue-bridge
  • Prospecting & maps: /prospect, /area-map, /insites, /proposal, /seo-gap-audit
  • Performance & data: /ads-report, /ads-stats, /analytics, /search-console, /gbp-check, /gmb-report, /ahrefs, /keyword-gaps, /category-keywords, /merchant-centre, /call-tracking, /ads-aimax-audit
  • Ops: /deploy-worker, /bq-query

They’re how a power user drives the platform from the terminal/agent. Same data, same Worker — a different (and the most flexible) front door. They’re versioned in the repo so the agent’s behaviour is reproducible.


All the logic lives in a single Cloudflare Worker called fcr-dashboard-api, served on the branded custom domain api.fcrinsights.ie (also still reachable at fcr-dashboard-api.fcrmedia.workers.dev).

A Cloudflare Worker is serverless edge compute: there’s no server and no region. Cloudflare runs the code in a lightweight V8 isolate per request, in whichever of its ~330 global data centres is nearest the caller, then tears it down. The same code runs everywhere, on demand. That’s why responses are fast no matter where the rep is.

It’s a monolith: 176 endpoint handlers (account lookup, enrichment, keyword intelligence, the AI advisor, the deal-brief system, etc.) are bundled into one Worker. It doesn’t call other Workers — everything is in the one deployable unit.

The Worker also runs on a schedule (cron triggers) and as a queue consumer, not just on web requests — that’s how background jobs like cache pre-warming and the deal-brief refresh happen.


The Worker holds bindings to Cloudflare data services and reaches out to external systems. It doesn’t store data itself — it orchestrates.

BigQuery (the warehouse) — the system of record for analytical data:

  • hubspot_deals — currently-open deals (refreshed daily).
  • hubspot_deals_backcatalogue_18m18 months of closed won/lost deals (~10,800), assembled from three rolling 6-month cohorts, plus a frozen year-on-year comparison pair.
  • hubspot_engagements — every note / email / call / meeting (~230k).
  • active_clients, keyword intelligence, GBP performance, census/area data, etc.
  • v_opportunity_tier — the 20-Minute Market (signed off June 2026): one row per (category × locality) saying whether an established website already competes inside the business’s real service radius (THIN / CONTESTED / SATURATED), at what market depth (households per competitor), and therefore which product rung is the honest pitch. Built from drive-time catchments (drive_time_catchments), the 100%-coverage category bridge (category_bridge + canonical_key_class), CSO census overlay (drive_time_catchment_demographics) and an observed-rankings evidence base (serp_grid_results, 109K rows). Consumers must read the VIEW and honour its conventions (never filter minutes=20; missing row = ultra-THIN; respect confident). Full reference: Opportunity Tiers · skill: /opportunity-tier · rebuild tooling: scripts/opportunity-tier/.

Vectorize (semantic search indexes) — for “find things like this”:

  • fcr-deal-history — past deals, so the advisor can say “deals like this one won/lost because…”.
  • fcr-site-portfolio — FCR-built sites, for “similar clients in this category”.
  • fcr-company-knowledge — FCR’s own knowledge base.

KV (instant key-value cache) — sub-30ms reads at the edge, e.g. the precomputed deal briefs.

External, live — BigQuery REST, the CRM API, Google (SerpAPI/Places), Pleper (GBP), Ahrefs, Meta. These are the slow sources, which is exactly why we pre-compute (next section).


5. The deal-brief system (why it’s instant)

Section titled “5. The deal-brief system (why it’s instant)”

The problem: when a rep opened a deal, the advisor used to fan out 4–5 live calls (CRM, Google Business Profile, website crawl, similar deals, category intelligence) while the rep waited — 15–30+ seconds every time.

The fix: precompute a complete deal brief for every open deal, ahead of time, and cache it. When a rep opens the deal, the advisor reads one cached blob in ~30ms instead of making the rep wait for live lookups.

How it’s kept fresh:

  • A background job (cron, every 2 hours during the day) refreshes briefs in small batches via a queue, so the heavy lookups happen when nobody’s waiting.
  • Opening a deal self-heals it — if its brief is out of date, opening it triggers a background refresh so it’s current next time.
  • The brief carries everything the four canonical rep questions need: “What’s this deal about?”, “Run prospect intel on this category”, “What was last discussed?”, “Draft a proposal note” — all answered from the cached brief with no live wait.

A brief bundles: deal facts, CRM + billing, Google Business Profile (live for prospects we don’t have on file), a website crawl, category keyword intelligence, the local area picture, similar paying clients, similar past deals, and the recent conversation history (real rep contact ranked above automated emails).


The advisor is Claude (Anthropic) running inside the Worker, given a toolbox of ~73 functions — look up an account, fetch a deal brief, run prospect intelligence, search similar deals, draft an email from approved templates, post a note back to the deal, and so on.

When a rep asks a question, Claude decides which tools to call, the Worker runs them (now mostly instant cache reads), and Claude writes the answer in FCR’s voice. The same advisor serves the HubSpot card and Roam. On the card, the deal on screen gets special treatment: the Worker injects its precomputed brief into Claude’s context before the first model call, so “what’s this deal about”-class questions answer in a single model pass with zero tool calls.

Guardrails are built into its instructions — e.g. it reads the injected brief first, never presents free directory listings as paying clients, and frames a brand-new invisible business as a greenfield opportunity rather than a data gap.


7. End-to-end: what happens when a rep opens a deal

Section titled “7. End-to-end: what happens when a rep opens a deal”
1. Rep opens a deal in HubSpot → the Prospect Advisor card loads.
2. The card pre-warms: a fresh brief is ensured AND the rep's identity +
template libraries are resolved into KV (keyed by their HubSpot email).
3. Rep clicks "What's this deal about?" → the turn is enqueued onto
fcr-hubspot-chat-queue (worker-native; no n8n hop since 2026-06-10).
4. The Worker reads the brief from KV (~30ms) and INJECTS it into Claude's
context — one model pass, zero tool calls for the deal on screen.
5. Claude writes the answer, streamed back into the card; completion is
signalled through the ProgressDO so the card's poll sees it instantly.
(No live CRM/Google/website calls in the rep's path — already precomputed.)

Behind the scenes, on a schedule, the Worker keeps every open deal’s brief fresh by composing them in the background through a queue.


  • Source of truth: the Git repo (github.com/FCR4IE/DASHBOARD).
    • Worker: worker/src/** (entry index.js, router.js, 126 handlers/).
    • Web UI: src/** (React/Vite).
    • HubSpot card: hubspot-extension/src/**.
  • Deploy: scripts/deploy-worker.sh bundles worker/src/** into one script and ships it to Cloudflare (it lint-gates, refuses a dirty tree, refuses a production deploy from any branch but main — added 2026-06-10 — and tags every deploy for rollback). The UI builds with Vite and uploads to Pages. The card uploads to HubSpot.
  • Two Cloudflare accounts: FCR Media (production — …fcrmedia.workers.dev) and a personal dev account (…cathaldempsey.workers.dev).

On Cloudflare the Worker is stored as one compiled bundle, not individual files — the individual files only exist in the repo. The CF dashboard’s “Workers bound” panel counts other Workers this one calls via service bindings — exactly one: CREATOR_GENcreator-image-gen (the advisor’s image-generation gateway on the same account).


9. Runtime, bindings & secrets (reference)

Section titled “9. Runtime, bindings & secrets (reference)”

Snapshot of the production Worker (fcr-dashboard-api, FCR account) as configured 2026-06-10.

Connected bindings (19) — resources the Worker talks to

Section titled “Connected bindings (19) — resources the Worker talks to”
TypeBinding nameResourceWhat it’s for
R2 bucketADVISOR_CSVfcr-advisor-csvCSV attachments the advisor generates
R2 bucketSERP_SNAPSHOTSfcr-serp-snapshotsdurable SERP HTML/PNG snapshots
KVCACHECACHEgeneral cache + deal-brief:<id> + advisor-identity:<email> blobs
KVREPORTSfcr-REPORTSDiscovery LRC reports + shared artefact sidecars
QueueBRIEF_QUEUEfcr-deal-brief-queuedeal-brief refresh jobs (producer + consumer)
QueueDEAL_VECTOR_QUEUEfcr-deal-vector-queuedaily incremental deal-history re-embeds (+ DLQ)
QueueADVISOR_QUEUEfcr-advisor-queueheavy Roam advisor runs handed off the sync path (+ DLQ)
QueueHUBSPOT_CHAT_QUEUEfcr-hubspot-chat-queueHubSpot card chat turns (transport since 2026-06-10)
VectorizeVECTORIZEfcr-site-portfoliosimilar FCR sites / clients
VectorizeCOMPANY_KNOWLEDGE_VECTORIZEfcr-company-knowledgecompany knowledge-base search
VectorizeDEAL_VECTORIZEfcr-deal-historysimilar past deals
VectorizeDOCS_VECTORIZEfcr-internal-docs”Ask the docs” RAG over this docs site
VectorizeOUTREACH_VECTORIZEfcr-outreach-templatesmarketing-approved outreach template search
VectorizePROSPECT_VECTORIZEfcr-prospect-opportunitiesprospect-spine opportunity search
Durable ObjectPROGRESS_DOProgressDOlive chat-progress + completion signal + STT hand-off
BrowserBROWSERheadless Chrome (SERP screenshots / renders)
ServiceCREATOR_GENcreator-image-genadvisor image generation (same-account Worker)
Analytics EngineAPI_USAGEfcr_api_usageworker-originated external API call counter
Analytics EngineCRAWL_METRICSfcr_crawl_metricsprospect-crawler telemetry (tick + slow-host rows)

Plaintext variables (non-sensitive config): BQ_PROJECT_ID, GOOGLE_ADS_CUSTOMER_ID, GOOGLE_ADS_LOGIN_CUSTOMER_ID, N8N_BASE, SELF_BASE_URL, DASHBOARD_BASE_URL, BQ_MAX_BYTES_BILLED, BQ_ALERT_ROAM_ID, CF_ACCOUNT_ID, HUBSPOT_CHAT_TRANSPORT ("queue" | "n8n" — the card-chat transport switch).

Secrets (encrypted; values never in the repo — set via wrangler secret put). What each unlocks:

SecretUnlocks
API_KEYthe x-api-key all UI/advisor calls authenticate with
ADMIN_API_KEYextra gate for /dashboard-bq-admin (DDL/writes)
ANTHROPIC_API_KEYClaude — the advisor
OPENAI_API_KEYembeddings (vector search) + Whisper STT
BQ_SERVICE_ACCOUNT_JSONBigQuery auth
HUBSPOT_ACCESS_TOKENlive HubSpot deal/engagement reads
SERPAPI_KEYGoogle Maps / SERP
PLEPER_API_KEY, PLEPER_API_SIGlive Google Business Profile data
AHREFS_API_KEYAhrefs SEO data
GOOGLE_ADS_CLIENT_ID/SECRET/DEVELOPER_TOKEN/REFRESH_TOKENGoogle Ads API
META_ACCESS_TOKEN, META_AD_ACCOUNT_IDMeta reach estimates
INSITES_API_KEY_DF, INSITES_API_KEY_LRC, INSITES_PUBLIC_KEYInSites audits
N8N_API_KEYn8n webhooks
ROAM_BEARER, ROAM_DEV_CHAT_ID, ROAM_DEV_GROUP_IDRoam advisor posting
TEAMWORK_DESK_API_KEY, TEAMWORK_DESK_WEBHOOK_SECRETTeamwork tickets
TEAMWORK_PROJECTS_API_KEYTeamwork Projects reads (falls back to the Desk key)
YEXT_POSTS_API_KEYPost Studio publish (Yext Posts API)
AE_READ_TOKENquerying the Analytics Engine datasets back via the SQL API
TypeSchedule / sourceJob
Cron* * * * * (every minute)Pleper + SerpAPI prospect enrich + crawler tick + HubSpot engagements drain
Cron30 6 * * *daily incremental fcr-deal-history vector reindex (changed deals only)
Cron40 6 * * *pre-warm AM portfolio caches (before reps start 08:00 local)
Cron50 6 * * *pre-warm check-account for open-deal subscribers
Cron0 8-20/2 * * *chunked deal-brief refresh (08:00–20:00 every 2h)
Cron5 8 * * 1 (weekly Mon)DM advisor-usage + external-API-usage reports to Ro.am
Cron0 9 * * *scheduled-jobs health monitor — DMs Ro.am only if a cron/feed is overdue
Queuefcr-deal-brief-queuequeue() consumer composes briefs
Queuefcr-deal-vector-queue (+-dlq)embeds deal batches; DLQ DMs Ro.am on dead batches
Queuefcr-advisor-queue (+-dlq)finishes heavy advisor runs off the request path, posts to Roam
Queuefcr-hubspot-chat-queueruns a HubSpot card chat turn + deposits the result (no DLQ — the card’s poll timeout covers a dropped turn)
  • Compatibility date: 2024-09-23 · flags: nodejs_compat
  • Placement: default (CF picks the edge location per request)
  • CPU time limit: raised to 300s via [limits] cpu_ms = 300000 (both accounts) — a backstop against Cloudflare 1102 on heavy single invocations (the advisor loop + inline crawls). Handlers are still I/O-bound — a compose is 14–46s of wall time but only a few hundred ms of CPU (waiting on BQ/SerpAPI/Pleper/KV doesn’t count as CPU) — so the ceiling is rarely approached.
  • Observability: Workers Logs enabled (codified in wrangler.toml [observability] enabled = true + [env.fcr.observability] since 2026-05-20), so wrangler tail and the Logs dashboard work on both accounts. Traces remain off (not needed for an I/O-bound monolith).
  • Two accounts: production = FCR Media (…fcrmedia.workers.dev), dev = personal (…cathaldempsey.workers.dev). Secrets/bindings exist in both.
TermPlain meaning
WorkerServerless code that runs at Cloudflare’s edge, per request
PagesCloudflare’s static-site hosting (the web UI)
EdgeCloudflare’s ~330 global data centres; code runs at the nearest one
BindingA connection from the Worker to a data service (KV, Vectorize, Queue…)
BigQuery (BQ)Google’s data warehouse — FCR’s analytical system of record
VectorizeCloudflare’s semantic-search index (“find things like this”)
KVCloudflare’s fast key-value cache
QueueBackground job pipeline — spreads slow work across many runs
Cron triggerA schedule that runs the Worker automatically
Deal briefA precomputed bundle of everything known about a deal
The advisorClaude + a toolbox, answering rep questions in FCR’s voice
Ask the docsRAG over this site
Ask anything about the FCR Dashboard platform — architecture, BigQuery, the worker routes, billing rules, the LRC stack, scoring… Answers are grounded in this documentation, with source links.
How does the deal-brief refresh work? Which routes are Worker vs n8n? How is account health scored?