Team Environments — Building on the Data Lake
Note (2026-05-22): for marketing’s targeting-list work we chose a lighter, skills-first path instead of the separate service-bound worker described below — see
marketing-skills.md. Skills read the dashboard API directly and route every write through n8n; nofcr-marketing-apiworker is used. The own-n8n-Community + own-BQ-dataset + idempotency-ledger guidance here still applies. This doc remains the reference for the heavier own-worker pattern when a future team genuinely needs an isolated runtime.
When a team (e.g. marketing) wants to build and experiment on FCR’s data lake without branch-fighting on the core dashboard, give them their own sandbox: their own repo, their own Cloudflare Worker, their own (self-hosted) n8n, writing to their own BigQuery dataset — while reusing the dashboard’s data layer and sharing credentials. This is the end-to-end pattern. Runnable starter:
examples/marketing-worker/.
The shape (and the one idea behind it)
Section titled “The shape (and the one idea behind it)”A team gets an isolated place to work, but they read everything, share credentials, and reuse the dashboard’s endpoints. The isolation is about runtime + accidents, not trust — these are people you trust, who also work on the core dashboard. We isolate so a buggy experiment can’t (a) take down the rep-facing stack or the production automation, or (b) corrupt the data reps see live. Everything else, share.
| Layer | Isolated or shared | How |
|---|---|---|
| Source (git) | isolated | their own repo, not FCR4IE/DASHBOARD |
| Worker runtime | isolated | their own Cloudflare Worker (own deploy / CPU / cron / logs) |
| n8n runtime | isolated | self-hosted n8n Community, not the shared company cloud tenant |
| Credentials / secrets | shared | copy the existing values in (small, trusted team) |
| Data — reads | shared | read anything (fcr_operations, KI, …) via service binding or the BQ cred |
| Data — writes | isolated | write to their own dataset; fcr_operations is read-only to experiments |
| Dashboard endpoints | shared | service binding (worker) / public API + key (n8n) |
The two blast-radius truths to remember:
- The worker + self-hosted n8n isolate the runtime — an experiment can’t take down the rep-facing worker or the production n8n.
- BigQuery is the shared thing — isolation does not extend to data. So the “write to your own dataset” rule below is the real guardrail.
1. Source — their own repo
Section titled “1. Source — their own repo”A new repo (e.g. FCR4IE/marketing-worker), not the main DASHBOARD repo.
The whole point is to avoid branch-fights and let them move independently;
putting their worker in the main repo pulls them back into shared branches/PRs on
the dashboard’s terms. Because they consume the dashboard via the service
binding / API (not by importing its source), a separate repo costs almost
nothing. The examples/marketing-worker/
folder here stays as the template; the live one lives in their repo.
(Counter-case: a monorepo gives one-place visibility and easy code promotion, at the cost of branch contention. For a “don’t fight about branches” goal, separate repo wins.)
2. The worker
Section titled “2. The worker”A separate Cloudflare Worker in the FCR Media account (0481b38f…) — service
bindings + shared KV/Vectorize/R2 are account-scoped; cross-account isn’t
supported. It service-binds to fcr-dashboard-api and reuses its composed
endpoints rather than duplicating the data layer.
name = "fcr-marketing-api"main = "src/index.js"
# Call the dashboard worker directly — no public hop, no second copy of the BQ creds.[[services]]binding = "DASHBOARD"service = "fcr-dashboard-api"
# Optional: share READ resources by ID (same account)# [[kv_namespaces]]# binding = "CACHE"# id = "7115c43af9284b5bbfc96993e16b8ca2" # FCR prod CACHE# [[vectorize]]# binding = "DOCS_VECTORIZE"# index_name = "fcr-internal-docs"// env.DASHBOARD routes to fcr-dashboard-api; host is ignored, path matters.// The dashboard still enforces x-api-key, so pass it from a secret.async function dashboard(path, init = {}) { return env.DASHBOARD.fetch(`https://dashboard${path}`, { ...init, headers: { ...(init.headers || {}), "x-api-key": env.DASHBOARD_API_KEY }, });}const res = await dashboard(`/dashboard-check-account?subscriberId=${sid}`);The producer (fcr-dashboard-api) needs no changes — service-binding requests
hit its normal fetch, so its x-api-key auth still applies.
Deploy — their own, and trivial: just wrangler deploy from their repo (or a
5-line script). They do not use scripts/deploy-worker.sh — that’s hardwired
to the dashboard’s worker/ plus the heavy gate (clean-tree + no-undef + two
accounts + tags), which is overkill for a sandbox. Add a gate only if their
worker ever becomes something reps depend on.
3. Their n8n — self-hosted Community
Section titled “3. Their n8n — self-hosted Community”The company cloud n8n is a shared runtime: a heavy or looping experimental flow competes with the production ETL / sync / InSites workflows for task-runner slots, and we’ve already hit task-runner saturation on that tenant. So give the team a self-hosted n8n Community instance (free; cost is a container/VM) — their own isolated runtime.
- It isolates compute, not data. The box still talks to the same BigQuery, so the own-dataset write rule (§4) applies just as hard — arguably more, since there’s now zero friction to fire a big job.
- Keep production automation on the managed cloud n8n. Treat the Community box as a sandbox; don’t let critical scheduled jobs drift onto something you must patch and back up yourself.
- Secure the box — it holds real credentials. A self-hosted n8n on the open internet with the BQ service account + HubSpot creds is the one genuine risk here regardless of trust. Put it behind a login + HTTPS, ideally Cloudflare Access or a tunnel, never a bare public port.
- Promotion path: export the workflow JSON → import into the company tenant; keep the two on similar n8n versions so nodes don’t break on the way over.
4. Credentials — share, with one habit
Section titled “4. Credentials — share, with one habit”Small, trusted team → reuse the existing secrets (the BQ service account, the dashboard API key, HubSpot, …). Copy the values into their worker secrets and their n8n credential store. (Cloudflare secrets are per-worker, so it’s a copy of the value, not a live link — fine.)
The one habit to keep — accidents, not trust:
- Reads: share everything, read anything.
- Writes: experiments write to their own dataset (a shared
sandbox, ormarketing_ops); treat the livefcr_operationstables as read-only.
Why: those tables are what the advisor, HubSpot card, and dashboard read in
real time, so a stray DML or a runaway batch loop would show reps wrong data —
not just break a sandbox. Most of fcr_operations self-heals on the next sync
(active_clients rebuilds daily), but the Vectorize indexes and hand-built
tables don’t.
Optional future hardening (not needed now): if you ever want that enforced
rather than by convention, mint a service account with bigquery.jobUser
(project) + dataViewer on fcr_operations + dataEditor on their dataset
only. Zero flexibility lost — reads stay wide open. Don’t bother until an
accident or a headcount makes it worth it.
⚠️ One sharp edge: handing a team the dashboard API key gives them
dashboard-bq-execute, which runs arbitrary SQL with the powerful dashboard service account — a back door to writingfcr_operations. With a trusted team that’s acceptable; just know it’s there, and lean on the own-dataset habit.
5. Their own BigQuery
Section titled “5. Their own BigQuery”Their writes land in their own dataset (e.g. marketing_ops or a shared
sandbox) — created once. Tracking tables and experiment outputs live there.
Design tracking tables as idempotency ledgers: one row per item with a unique
key + created_at/sent_at + status, so a re-run never double-processes.
Worked example — Graham’s Listing-Manager reactivation flow
Section titled “Worked example — Graham’s Listing-Manager reactivation flow”Goal: take open Listing-Manager partials, check if they’re on file, and email each a completed listing + a short-form LRC for their keywords.
| Step | Where it runs | Reuses |
|---|---|---|
| Input: open LM partials | confirm the feed first — BQ table / LM export / webhook | — |
| On-file check | his worker (service binding) | dashboard-check-account / -check-keywords (subscriber-id bridges absorb name drift) |
| Complete listing + short LRC keywords | his worker | dashboard-discovery-suggest (keywords/satellites) + dashboard-serp-grid (quick rank) |
| Render short-form text + send email | n8n Community (email node / ESP) | — |
| Track what was sent + when | his dataset, e.g. marketing_ops.lm_partial_sends | idempotency ledger — no double-sends |
Placement: the heavy part (the partials sweep, the LRC fan-out, the throttled
send loop) lives in his worker (isolated, own cron); n8n does the light glue
and the email node. Tracking writes go to his dataset, never fcr_operations.
Compliance: outreach email to partials needs consent + unsubscribe (GDPR) and SPF/DKIM on the sending domain — settle before the first send.
When to just add to the core instead
Section titled “When to just add to the core instead”For a tightly-coupled feature the rep-facing surfaces consume directly (a new
advisor tool, an account-panel endpoint), add a route to fcr-dashboard-api —
you avoid a second deploy target and shared-code drift. Reach for a team
environment when the work is team-owned, experimental, or operationally heavy.
Promotion path (experiment → product)
Section titled “Promotion path (experiment → product)”- Worker code: move it into the main repo’s
worker/as a route, or keep it as its own worker if it stays team-owned. - n8n: export the JSON from Community → import to the company cloud tenant.
- Data: promote a sandbox table into
fcr_operationsvia the normal sync / scheduled-query path (reviewed) — not by pointing experiments at prod.
Starter: examples/marketing-worker/. Related: fcr-dashboard-architecture-overview.md, external-apis.md, bigquery-and-sync.md, commit-and-deploy.md.