<!--
SEO & GEO Engine Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# SEO & GEO Engine Playbook

**Portable spec for an autonomous trend-discovery → AI-authoring → publish → index-tracking engine, run from a SaaS app's admin console.**

This is the *operational engine* that sits on top of [TOPICAL-AUTHORITY-PLAYBOOK](../s-tier/TOPICAL-AUTHORITY-PLAYBOOK.md). Topical-Authority defines the page *strategy* (5-layer model, collections, schema discipline). This playbook is the *machine* that keeps that surface growing on its own: it watches the web for fresh entities (new AI tools, competitor moves, topics), classifies each into the right page-types, dispatches an AI writer to author + fact-check + publish real pages, and tracks their grades + Google/Bing traction + index status — all from an admin console, twice a day, with zero human touch once enabled.

Built from the BKE / Kompozy implementation (shipped 2026-06; autonomous mode 2026-06-23). Reference repo: `reference app`. Origin design doc: `SEO_GEO_ENGINE_GAME_PLAN.md` in that repo.

**Depends on (build these first):** [APP-INFRASTRUCTURE-PLAYBOOK](../s-tier/APP-INFRASTRUCTURE-PLAYBOOK.md) (Supabase + Trigger.dev + GitHub + Vercel), [ADMIN-CONSOLE-PLAYBOOK](../s-tier/ADMIN-CONSOLE-PLAYBOOK.md) (the admin shell + `requireAdmin`), [TOPICAL-AUTHORITY-PLAYBOOK](../s-tier/TOPICAL-AUTHORITY-PLAYBOOK.md) (the collections + page render model this authors INTO), [TRIGGER-DEV-WORKER-PLAYBOOK](TRIGGER-DEV-WORKER-PLAYBOOK.md) (the worker pattern). Pairs with [ADMIN-ANALYTICS-DASHBOARD-PLAYBOOK](ADMIN-ANALYTICS-DASHBOARD-PLAYBOOK.md) (same admin-tab shape).

---

## 0. Use this spec

**Inputs (decide these per app):**
- The app's **collections** (page-types the engine can author into). BKE uses 9: `ai-tools, news, alternatives, compare, reviews, glossary, roundups, how-to, guides`. A new app picks its own set (a legal-SaaS might use `case-types, statutes, glossary, guides, compare`).
- The app's **niche entities to watch** — a competitor list + topic queries (the "knobs" in §5). For Kompozy: AI content/video tools. For another app: whatever entity-space ranks page-1 on freshness.
- A **product bridge** — the one paragraph every authored page must contain that ties the trend back to YOUR product (the GEO/conversion hook). Kompozy's is "how Kompozy turns this tool's output into content across 9 platforms."
- Supabase project + Trigger.dev project + a GitHub repo with Actions enabled.
- An LLM key for entity extraction (OpenAI in BKE) and an AI-author credential for the GitHub Action (Claude Code OAuth token in BKE).
- Optional: Google Search Console service account, Bing Webmaster API key, IndexNow key.

**Outputs (what you get):**
- 6 Supabase tables: `seo_pages`, `seo_traction`, `page_index_status`, `trend_candidates`, `trend_pages` (legacy/optional), `seo_sync_runs`.
- A twice-daily `poll-trends` cron worker that discovers + classifies + auto-authors.
- A multi-source trend catalog (`trend-sources.ts`) — the single place to widen/narrow the net.
- An auto-author dispatcher + a GitHub Action that writes, fact-checks, and commits real pages, with a live callback loop.
- A 3-layer analytics surface (grades / traction / index) with GSC + Bing + IndexNow sync.
- An admin console section (`SEO & GEO`) with Analytics + Discovery tabs.

---

## 1. The model (read this first)

```
                    ┌──────────────── twice daily (cron) ────────────────┐
                    ▼                                                     │
  [ web sources ] → poll-trends worker → classify (LLM, 2 passes) →  trend_candidates
   HN · RSS · Google           │              tool / competitor / topic      │ (status='new')
   News · Reddit · PH          │                                             │
                               │  auto-author every NEW candidate            ▼
                               └────────────► dispatchAuthorBatch() ──► GitHub Action
                                                                       (workflow_dispatch)
                                                                              │
        ┌─────────────────────────────────────────────────────────────────┘
        ▼  (Claude Opus, serialized matrix, max-parallel:1)
   author page(s) → fact-check pass → commit to data module → git push → Vercel deploys
        │                                                                     │
        │  callbacks at each stage (authoring→verifying→publishing→published) │
        ▼                                                                     ▼
   /api/seo/author-callback  ──►  updates trend_candidates.author_jobs    LIVE PAGE
        │                          + pipeline_stage  (Discovery UI watches)  (SSG)
        ▼                                                                     │
   Discovery tab shows live pipeline + per-collection chips                   │
                                                                              ▼
   [ GSC + Bing + IndexNow sync ] → seo_traction + page_index_status → Analytics tab
                                     (grades + clicks/impressions + index state, per page)
```

**The core insight (why this exists):** brand-new entities (a just-launched tool, a fresh competitor) have **zero ranking competition and zero authority requirement** — they rank page-1 on freshness alone, and LLMs cite first-movers. Trend-jacking is the one organic play that ranks without backlinks. The engine industrializes it: discover the entity within hours, author a compliant page (original + timely + product-bridged = not "scaled content abuse"), publish before competitors notice.

**Two things that make it autonomous and safe:**
1. **Auto-author on discovery** — newly-inserted candidates immediately fire the authoring Action; no human gate once enabled (the gate is *earned away* after a labeling period — see §12).
2. **The author is a real CI agent, not a template** — the GitHub Action runs Claude Opus with web search, authors per-collection with distinct angles, runs a **fresh-eyes fact-check pass**, typechecks, and only then commits. Quality-by-construction, not string interpolation.

---

## 2. Architecture (file tree)

```
src/
├── trigger/
│   ├── poll-trends.ts                 # CRON twice daily: discover → classify → auto-author
│   ├── sync-gsc.ts                    # CRON daily: Google Search Analytics + URL Inspection → DB
│   └── sync-bing.ts                   # CRON daily: Bing index status + URL submission → DB
│
├── lib/
│   ├── trend-sources.ts               # THE KNOBS: every source + feed/query/competitor catalog
│   ├── seo/
│   │   └── author-dispatch.ts         # dispatchAuthorBatch() — fires the GitHub Action (shared)
│   ├── repo/
│   │   └── seo-engine.ts              # server-only repo: types + CRUD + gradeTrendPage + analytics joins
│   ├── seo-existing-pages.ts          # getExistingSlugsByCollection() — dedupe vs already-covered
│   ├── seo-live-audit.ts              # getLiveAuthoredPages()/isLiveAuthoredUrl() — live overlay
│   ├── seo-bing.ts                    # Bing Webmaster API client (status sweep + submit + quota)
│   └── <collection data modules>      # ai-tools.ts, news.ts, glossary.ts, ... (the authored content)
│
├── app/
│   ├── admin/dashboard/seo-geo/
│   │   ├── page.tsx + SeoGeoView.tsx  # Analytics tab (grades + traction + index, filters, "Pull now")
│   │   ├── SubTabs.tsx                # tab bar (Analytics · Discovery)
│   │   ├── fmt.ts                     # ET date formatter
│   │   └── discovery/
│   │       ├── page.tsx + DiscoveryView.tsx   # Discovery tab (candidates, kinds, live pipeline)
│   │
│   ├── api/
│   │   ├── admin/seo/
│   │   │   ├── pull-trends/route.ts        # POST → tasks.trigger('poll-trends')  [requireAdmin]
│   │   │   ├── author/route.ts             # POST → dispatchAuthorBatch()         [requireAdmin]
│   │   │   ├── candidate-status/route.ts   # POST → setCandidateStatus()          [requireAdmin]
│   │   │   ├── refresh-analytics/route.ts  # POST → trigger sync-gsc/sync-bing + IndexNow [requireAdmin]
│   │   │   └── sync-status/route.ts        # GET  → recent sync runs + Bing quota  [requireAdmin]
│   │   └── seo/
│   │       └── author-callback/route.ts    # POST ← GitHub Action [secret-auth, NOT admin session]
│   │
│   ├── <collection>/[slug]/page.tsx   # public SSG render of authored pages (per TOPICAL-AUTHORITY)
│   └── sitemap.ts                     # appends authored-page URLs from data modules
│
├── scripts/
│   ├── seo-geo-regrade.ts             # deterministic grader (reads .tsx + data modules off disk)
│   ├── seo-sync-grades.mjs            # npm run seo:sync-grades → upsert grades to seo_pages
│   ├── seo-sync-traction.mjs          # npm run seo:sync-traction → upload GSC scrape to seo_traction
│   └── submit-indexnow.mjs            # rapid Bing/Copilot indexing ping
│
├── .github/workflows/
│   └── author-trend-page.yml          # the AI-authoring CI brain (Claude Opus, serialized)
│
└── public/<indexnow-key>.txt          # IndexNow ownership key file
```

---

## 3. Data model

Six tables. Base schema is one migration (`00155_seo_geo_engine.sql` in BKE); later migrations added the autonomy columns. All tables are **admin-only RLS**; workers + scripts write via the service-role client (bypass RLS). Use `is_admin((select auth.uid()))` as the policy form (init-plan-cached; matches your admin migration).

```sql
-- 1. Grade cache (Layer A) — one row per page, refreshed by the regrade script
create table if not exists public.seo_pages (
  url          text primary key,
  slug         text not null,
  collection   text not null,
  category     text not null,
  seo          text not null check (seo in ('yes','no')),
  geo          text not null check (geo in ('yes','no')),
  indexable    text not null check (indexable in ('yes','no')),
  seo_missing  jsonb not null default '[]',
  geo_missing  jsonb not null default '[]',
  notes        text,
  graded_at    timestamptz not null default now()
);

-- 2. Traction snapshots (Layer B) — per-URL per-day per-engine search metrics
create table if not exists public.seo_traction (
  url          text not null,
  captured_on  date not null,
  source       text not null default 'gsc' check (source in ('gsc','bing')),
  clicks       int  not null default 0,
  impressions  int  not null default 0,
  avg_position numeric,
  primary key (url, captured_on, source)
);

-- 3. Per-URL index status (Layer B) — Google + Bing
create table if not exists public.page_index_status (
  url                text not null,
  engine             text not null check (engine in ('google','bing')),
  status             text not null,  -- indexed|crawled_not_indexed|discovered|excluded|submitted|unknown
  last_checked_at    timestamptz not null default now(),
  first_published_at timestamptz,
  primary key (url, engine)
);

-- 4. Trend candidates (Discovery) — the discovery queue + autonomy state
create table if not exists public.trend_candidates (
  id                 uuid primary key default gen_random_uuid(),
  entity             text not null,                 -- "Sora 2", "OpusClip", "faceless YouTube"
  dedupe_key         text not null unique,          -- normalized slug; the cross-run dedupe gate
  source             text not null,                 -- 'google-news'|'hackernews'|'reddit'|'mixed'|...
  source_url         text,
  trend_score        numeric not null default 0,    -- cross-source corroboration count
  summary            text,
  -- classifier output:
  target_collection  text not null default 'ai-tools',  -- PRIMARY page-type (= target_collections[0])
  target_collections text[] not null default '{}',      -- full fan-out set (one page per entry)
  kind               text not null default 'tool',      -- 'tool'|'competitor'|'topic' (signal lens)
  -- autonomy / authoring state:
  author_jobs        jsonb not null default '{}'::jsonb, -- { "<collection>": {status,url?,at?} } per page
  status             text not null default 'new'
                     check (status in ('new','queued','drafting','drafted','rejected','published')),
  pipeline_stage     text,                          -- queued|authoring|verifying|publishing|NULL (live micro-stage)
  created_at         timestamptz not null default now(),
  updated_at         timestamptz not null default now()
);

-- 5. Trend pages (LEGACY/optional) — DB-stored page bodies for the DB-ISR render model (see §7.2)
create table if not exists public.trend_pages (
  id               uuid primary key default gen_random_uuid(),
  candidate_id     uuid references public.trend_candidates(id) on delete set null,
  slug             text not null unique,
  title            text not null,
  meta_description text,
  body_mdx         text not null,
  faq              jsonb not null default '[]',
  bridge_ok        boolean not null default false,
  factcheck        jsonb,                            -- { verdict, claims:[{claim,sources[],supported}], notes }
  seo              text not null default 'yes',
  geo              text not null default 'yes',
  status           text not null default 'draft'
                   check (status in ('draft','approved','published','rejected','archived')),
  approved_by      uuid,
  published_at     timestamptz,
  created_at       timestamptz not null default now(),
  updated_at       timestamptz not null default now()
);

-- 6. Sync run tracking — so the Analytics UI can show "sync running…" + last-run + quota
create table if not exists public.seo_sync_runs (
  id          uuid primary key default gen_random_uuid(),
  task        text not null,                         -- 'sync-gsc' | 'sync-bing'
  status      text not null default 'running' check (status in ('running','done','failed')),
  detail      jsonb not null default '{}',
  started_at  timestamptz not null default now(),
  finished_at timestamptz
);

-- RLS: admin-only on all six. Workers/scripts use service-role (bypass).
-- updated_at triggers on trend_candidates + trend_pages (tg_set_updated_at from your base schema).
-- Indexes: seo_traction(url); trend_candidates(status, created_at desc), (kind,...), (target_collection,...);
--          trend_pages(status, published_at desc); page_index_status(engine, status); seo_sync_runs(task, started_at desc).
```

**As-built column history (BKE):** base `00155` shipped tables 1–5 with `trend_candidates` carrying only `{entity,dedupe_key,source,source_url,trend_score,summary,status}`. Autonomy columns were added incrementally — `target_collection`, then `target_collections`, then `author_jobs`, then `pipeline_stage`, then `seo_sync_runs` (its own table), then `kind`. **In a new app, ship the full consolidated schema above in one migration** — there's no reason to replay the incremental history. The reference repo's `src/lib/repo/seo-engine.ts` types are the source of truth for the final shape.

---

## 4. Discovery worker (`poll-trends.ts`)

A `schedules.task` cron. Reference: `src/trigger/poll-trends.ts`.

**Cron:** twice daily, timezone-anchored. BKE uses `{ pattern: '0 9,17 * * *', timezone: 'America/New_York' }` (9 AM + 5 PM ET — `America/New_York` auto-handles EST/EDT). Trigger.dev's `cron` accepts a string OR `{ pattern, timezone }`; a multi-hour pattern (`9,17`) fires twice from one task. Fresh news → published pages the same morning/evening it breaks.

**Run flow:**
1. `collectAllSources()` (§5) pools every source, deduped by URL → `RawItem[]` with `tsMs`.
2. **Freshness gate** — drop anything older than `FRESH_WINDOW_HOURS` (48h in BKE); items with unknown timestamp (`tsMs=0`) fall below the cutoff and are dropped.
3. **Two LLM extraction passes** over the same fresh pool (OpenAI `/v1/responses` in BKE; any structured-output LLM works):
   - **Pass A "tool"** → `kind='tool'`: named, current tools/models a creator could use. Skips long-established names unless the headline is a concrete new release.
   - **Pass B "intel"** → `kind='competitor'|'topic'`: emerging competitors + their moves (primary collection `alternatives`), and content/format/algorithm topics (primary `guides`/`how-to`/`roundups`/`glossary`).
   - Each item returns `{ entity, summary, collections[] (primary first), kind }`. The prompt teaches the collection taxonomy (§0 collections) and how to pick the *set* (one entity → 1–4 page-types, "don't pad").
   - **Tuning levers that matter:** feed enough headlines per pass (BKE caps at 140), and set `max_output_tokens` high enough that the JSON array isn't truncated mid-tail (BKE uses 6000 — too low silently drops the back of the list).
4. **Merge passes** by `normalizeDedupeKey(entity)` — intel kind wins on collision (rarer/more valuable signal), collections union.
5. **Dedupe vs already-covered** — `getExistingSlugsByCollection()` (§ data modules) returns per-collection sets of existing slugs; keep only the collections that DON'T already have a page for this entity. An entity covered in *every* applicable collection is skipped entirely (`fullyCovered`). **This means raw yield naturally decays as coverage fills in** — counteract with bigger caps + more sources.
6. **Score** = cross-source corroboration: how many pooled headlines mention the entity (proxy for "actually trending").
7. **Cap per kind** — sort by score, take top N tool + top N intel (BKE: 20 tool + 15 intel). Per-kind caps so tool launches can't crowd out competitor/topic intel. **These caps are the hard ceiling on candidates/day** — raise them (and the run cadence) to scale volume.
8. **Insert** — `insertNewCandidates()` upserts with `onConflict: 'dedupe_key', ignoreDuplicates: true` and **returns only the newly-inserted rows**. First run to surface an entity sets its `kind`/`status` permanently; later re-discovery is a no-op (preserves a candidate an operator already rejected). Cross-run immutability by design.
9. **Auto-author** — for each newly-inserted row, call `dispatchAuthorBatch()` (§6) with its `target_collections`. This is the autonomy step: discovery → published pages, same run, no human. Graceful degrade: if the dispatch token is missing, candidates simply stay `new` for manual authoring.

**Env:** the LLM key (BKE reads an enterprise OpenAI slot via admin settings; hard-fails gracefully to `[]` if the slot isn't OpenAI), and `GITHUB_DISPATCH_TOKEN` **in the Trigger.dev env** (see §11 — this is the #1 setup gotcha).

---

## 5. The source catalog (`trend-sources.ts`) — the knobs

The single place to widen/narrow the net. Worker-bundled: imports only the Trigger logger + global `fetch`; **never** `next/headers` or a server-only repo (keeps the Trigger build lean). Every fetcher degrades to `[]` on failure (logs a warn) — one dead source never breaks a run. Reference: `src/lib/trend-sources.ts`.

**Source reality (keep this honest per app — BKE probed 2026-06):**
| Source | Keyless? | Notes |
|---|---|---|
| **Hacker News** (Algolia API) | ✅ | Front-page + date-sorted AI stories. Timestamped, links to primary source. |
| **News-vertical RSS** (TechCrunch/VentureBeat/Verge AI feeds) | ✅ | Reliable second source. |
| **Niche/marketing RSS** (Search Engine Journal, Social Media Examiner, etc.) | ✅ | Surfaces topics + algorithm shifts the pure-tech feeds miss. |
| **Google News query-RSS** | ✅ | **The wide net + competitor radar.** Any term → up to ~40 timestamped items/query. Competitor names + topic phrases ride here (their own blogs usually have no RSS). |
| **Reddit** (OAuth client-credentials) | ⚠️ key | Datacenter IPs get 403 without `REDDIT_CLIENT_ID/SECRET`. Velocity/buzz signal. |
| **Product Hunt** (GraphQL) | ⚠️ key | Best source for brand-new tool launches; needs `PRODUCT_HUNT_API_KEY`. |

**The catalogs you edit per app** (exported consts): `AI_CONTENT_FEEDS[]`, `<NICHE>_FEEDS[]`, `COMPETITORS[]` (also OR-joined into Google News queries), `GOOGLE_NEWS_QUERIES[]` (three buckets: tool-launch terms, topic/strategy terms, competitor-name OR-groups), `REDDIT_SUBS[]`. `FRESH_WINDOW_HOURS` is the recency dial. **To get more/newer candidates:** add queries + competitors here, raise the caps in §4.7, add a run to the cron, and turn on the key-gated sources.

**The RSS parser is deliberately dependency-free** — a crude regex split on `<item|entry>` extracting title/link/pubDate, with an HTML-entity decode. Good enough for discovery (we only need titles + timestamps), keeps the worker bundle tiny.

---

## 6. Auto-author: dispatcher + the GitHub Action + callback loop

This is the "writer." It is a **GitHub Action running a real AI coding agent**, not a templated string. The app dispatches a batch; the Action authors, fact-checks, commits, and calls back at each stage.

### 6.1 The dispatcher (`src/lib/seo/author-dispatch.ts`)
`dispatchAuthorBatch(inputs[])` — the single shared entry point, called by **both** the autonomous worker (§4.9) and the manual admin route (`/api/admin/seo/author`). Reference: `src/lib/seo/author-dispatch.ts`.
- Validates each input's collections against the known set; builds one `batch` array of `{ entity, collections (comma-joined), summary, source_url, candidate_id }`.
- Fires **one** `workflow_dispatch` for the whole batch: `POST https://api.github.com/repos/<owner>/<repo>/actions/workflows/author-trend-page.yml/dispatches` with `{ ref: 'master', inputs: { batch: JSON.stringify(batch) } }`, auth `Bearer ${GITHUB_DISPATCH_TOKEN}`.
- Then `seedAuthorJobs(candidate_id, collections)` — seeds a `drafting` job per (candidate, collection) and flips the candidate to `drafting`/`pipeline_stage='queued'` so the UI shows progress immediately.
- Returns `{ ok, pages, candidates[], skipped[], error? }`. No token → `ok:false` + graceful no-op.
- **Keep dispatch logic in this ONE module** — the manual route and the worker must not drift (BKE refactored the route to call this helper for exactly that reason).

### 6.2 The GitHub Action (`.github/workflows/author-trend-page.yml`)
Triggered by `workflow_dispatch` carrying the `batch` JSON. Reference workflow in the BKE repo.
- **Job `plan`** — parses `batch` into a matrix (one leg per candidate), outputs `matrix` + `count`, fails if empty.
- **Job `author`** with `strategy: { matrix, max-parallel: 1, fail-fast: false }` — **serialized**: legs run back-to-back so two candidates editing the same data file can't race (each leg checks out the latest `master` tip, so earlier legs' commits are visible). One leg failing doesn't kill the others.
  Per leg:
  1. checkout `master`, install deps, regenerate a content index (so the author sees every existing page and won't duplicate);
  2. **callback `stage: authoring`** → live UI;
  3. **author step** — `anthropics/claude-code-action` (Claude Opus) with a prompt that reads the app's content specs + capabilities doc + per-collection spec + the data modules, then authors **one new entry per collection with a DISTINCT angle**, appends to the collection's data module file, and runs `tsc --noEmit`;
  4. **callback `stage: verifying`** → fresh-eyes **fact-check pass** (Claude Opus + WebSearch/WebFetch): diff what was added, verify every claim (specs, dates, pricing) against a primary source, fix in place, typecheck again;
  5. **callback `stage: publishing`** → `git add` only the edited data files, commit, `git pull --rebase`, `git push` (Vercel auto-deploys the new SSG page);
  6. **per-collection callbacks** `status: published` (with the live URL) or `skipped` (data file unchanged = already covered);
  7. **failure step** → `status: failed` callback for all target collections.
- **Secrets the Action needs:** `CLAUDE_CODE_OAUTH_TOKEN` (the AI author), built-in `GITHUB_TOKEN` (git push), `SEO_AUTHOR_CALLBACK_SECRET` (callback auth). Permissions `contents: write`.

### 6.3 The callback route (`/api/seo/author-callback`)
`POST`, authenticated by a **shared secret** (`SEO_AUTHOR_CALLBACK_SECRET`), **not** an admin session (the Action has no user session). Reference: BKE `src/app/api/seo/author-callback/route.ts`.
- **Stage update** `{ candidate_id, stage }` → `setCandidatePipelineStage()` → updates `pipeline_stage` + keeps `status='drafting'`.
- **Job result** `{ candidate_id, collection, status, url? }` → `applyAuthorJobResult()` — merge-read-write of `author_jobs` (so concurrent per-collection callbacks don't clobber each other), then **rolls the candidate status up**: any job still `drafting` → candidate `drafting`; all jobs terminal → candidate `published` and `pipeline_stage` cleared.
- This route must be reachable without a Supabase session — add it to your edge-auth allowlist (BKE's `proxy.ts` `PUBLIC_API_PREFIXES`) or it 401s before the secret check runs.

---

## 7. How authored pages render

Two valid models. **BKE's as-built system uses Model A.**

### 7.1 Model A — data-module commit + SSG (as-built, recommended)
The Action commits authored entries into **TypeScript data modules** (`src/lib/ai-tools.ts`, etc.); the public route renders them with `generateStaticParams()` + `generateMetadata()` + the full JSON-LD `@graph` (Article + FAQPage + Speakable + E-E-A-T + Breadcrumb) defined in [TOPICAL-AUTHORITY-PLAYBOOK](../s-tier/TOPICAL-AUTHORITY-PLAYBOOK.md). `sitemap.ts` appends the data-module URLs.
- **Pro:** pages are pure SSG (fastest, most crawlable, zero runtime DB); content lives in git (versioned, reviewable, revertable); the fact-check + typecheck gate runs in CI before it's ever live.
- **Con:** publishing requires a git push + Vercel build (~minutes), and the author surface is a CI agent.
- **Live overlay** (`seo-live-audit.ts`): `getLiveAuthoredPages()`/`isLiveAuthoredUrl()` let the Analytics tab read the *current* data modules and overlay them on top of the `seo_pages` snapshot, so a freshly authored page shows in Analytics instantly (before the next regrade).

### 7.2 Model B — DB-stored + ISR (the original plan; `trend_pages` table)
The original design (and the `trend_pages` table + `gradeTrendPage()` + `factcheck` column) stored page bodies in Supabase and served them from a single ISR route `app/<collection>/[slug]/page.tsx` with `dynamicParams=true` + `revalidate`, reading published rows via a **headers-free service-role client** (`createServerSupabase`/`createAdminSupabase` import `next/headers` → force dynamic → destroy the static HTML; you MUST make a plain `@supabase/supabase-js` server-only reader with no `next/headers`). Publish = flip status + `revalidateTag('trend-pages')` (tag-based, because `revalidatePath` of a dynamic slug is unreliable).
- **Pro:** publish without a deploy (live in seconds), no CI author.
- **Con:** first DB-backed public route (new render mode, cold-slug burst risk); content not in git.
- BKE kept the `trend_pages` table + `gradeTrendPage()` for this path; the live system pivoted to Model A. **Pick one per app.** If you choose B, `gradeTrendPage(input)` is the runtime grader (SEO = title + meta + valid slug; GEO = FAQ + product-bridge + E-E-A-T + Speakable) that writes `seo`/`geo` onto the row, using the same vocabulary as the deterministic grader so Analytics counts them apples-to-apples.

> **Analytics lockstep (hard rule either way):** any new public route is a marketing surface — wire it to first-party analytics in the SAME commit (funnel step + matcher + admin path map + `page_events.funnel_step` CHECK migration), or `/api/track` 500s silently on every event. See [CUSTOMER-ANALYTICS-PLAYBOOK](CUSTOMER-ANALYTICS-PLAYBOOK.md).

---

## 8. The repo layer (`src/lib/repo/seo-engine.ts`)

Server-only (imports the service-role client → never import from a client component or a static public route). Hand-written types (no generated types), per repo convention. Reference: `src/lib/repo/seo-engine.ts`. Grouped exports:

- **Types:** `SeoPageRow`, `SeoTractionRow`, `PageIndexStatusRow`, `TrendCandidateRow`, `TrendPageRow`, `AuditPage`, `AuditCards`, `CollectionRollup`, `SyncRun`, enums `Grade`/`TrendKind`/`PipelineStage`/`IndexState`/`AuthorJob`.
- **Layer A (grades):** `upsertSeoPages()` (chunked 500), `getExistingSlugTokens()`.
- **Layer B (traction/index):** `upsertTraction()`, `upsertIndexStatus()`, `getIndexStatusMap()`, `getBingSubmitCandidates(limit)`, `getAllTrackedUrls()`.
- **Discovery:** `normalizeDedupeKey()`, `insertNewCandidates()` (returns inserted rows), `listTrendCandidates(status?)`, `getCandidateById()`, `setCandidateStatus()`.
- **Authoring state:** `seedAuthorJobs()`, `setCandidatePipelineStage()`, `applyAuthorJobResult()` (merge-read-write + status rollup).
- **Draft/publish (Model B):** `upsertTrendDraft()`, `publishTrendPage()`, `gradeTrendPage()`, `listTrendPagesByStatus()`, `setTrendPageStatus()`.
- **Analytics joins:** `getAuditPages()` (joins `seo_pages` + live overlay + traction + index → `AuditPage[]` + freshness stamps), `summarizeCards()`, `rollupByCollection()` (sorted by GEO gap desc).
- **Sync tracking:** `startSyncRun()`, `finishSyncRun()`, `getRecentSyncRuns()`.
- **Critical helper:** `selectAll<T>()` — **paginates with `.range()`**; PostgREST silently caps every query at 1000 rows regardless of `.limit()`. Any table that can exceed 1000 rows (traction, index status) MUST read through it.

---

## 9. Analytics: grades + traction + index (3 layers)

**Layer A — grades (local, pre-push).** `scripts/seo-geo-regrade.ts` reads the data modules + `.tsx` routes off disk (can't run in a serverless worker — it needs the filesystem) and applies the deterministic rubric:
- **SEO pass** = title + meta description + valid slug + derivable canonical.
- **GEO pass** = FAQPage (faq present) + product-bridge + E-E-A-T (author byline, `inLanguage`, visible "Last verified") + Speakable. Per-collection nuance allowed (e.g. glossary GEO gates on FAQ presence only).
- **indexable** = not `noindex`. Intentional `noindex` pages are exclusions, not failures.
`npm run seo:sync-grades` runs the grader then upserts every row into `seo_pages`. Run it locally whenever SEO data/code changes, before push. Idempotent (upsert by URL).

**Layer B — traction + index (workers OR local scrape).** Two ways, pick per app maturity:
- **Server crons (mature):** `sync-gsc.ts` (daily) authenticates a Google **service account** (`GOOGLE_GSC_SA_JSON_B64` + `GSC_PROPERTY`), pulls **Search Analytics** → `seo_traction` and **URL Inspection** (≤~600 URLs/run within the 2000/day quota, least-recently-checked first) → `page_index_status` (engine `google`). `sync-bing.ts` (daily) uses `BING_WEBMASTER_API_KEY` (`seo-bing.ts` client) to sweep index status + submit not-yet-indexed URLs (Google-indexed first) within the Bing daily quota. Both log to `seo_sync_runs`. **Never overwrite a real status with a transient `unknown`** — the API client returns `null` on a throttle/hiccup and the writer skips nulls (monotonic count).
- **Local scrape (bootstrap):** before you have a GSC service account, scrape the signed-in GSC report and `npm run seo:sync-traction` to upload it. Analytics shows a "last refreshed: never/<date>" stamp so staleness is visible, not faked.

**IndexNow** (`scripts/submit-indexnow.mjs` + `public/<key>.txt`): pings Bing/Copilot at publish for near-instant discovery. Key file MUST be deployed to the public root first (IndexNow validates ownership by fetching it). `/api/admin/seo/refresh-analytics` also fires an IndexNow batch (capped ~9000 URLs/call).

**The Analytics view** (`getAuditPages()` → cards + rollup + table): live `count(*)`-style aggregates (never hardcoded totals — they drift the moment a page publishes), per-collection rollup sorted by GEO gap, and a filterable per-page table (collection/seo/geo/indexable, missing-signals, clicks/impr/position, Google + Bing index state, published date).

---

## 10. The admin console UI

A top-level `SEO & GEO` nav item → `/admin/dashboard/seo-geo`, sub-tabs as separate routes (same pattern as any other admin section). Auth is automatic: the dashboard layout gates admin, and every `/api/admin/seo/*` route calls `requireAdmin()` then uses the service-role client.

**Analytics tab** (`SeoGeoView.tsx`): the 3-layer audit (cards, GSC reality-check panel, per-collection rollup, filterable page table). A **"Pull now"** button → `/api/admin/seo/refresh-analytics` (triggers `sync-gsc` + `sync-bing` + IndexNow); a 6s poll of `/api/admin/seo/sync-status` shows live sync run state + Bing quota; the pull deadline persists in `localStorage` so it survives navigation. Visibility-gated polling (stops when the tab is backgrounded — don't burn edge requests).

**Discovery tab** (`DiscoveryView.tsx`): the candidate queue. Status filter (new/drafting/published/rejected) + **kind filter** (🛠 tool / ⚔ competitor / 💡 topic) + per-candidate collection toggles. Each row shows entity, score, source, kind, summary, and — while authoring — a **live pipeline stepper** (queued → authoring → verifying → publishing) plus **per-collection job chips** (drafting/published/skipped/failed, published ones link to the live page). A 4s `router.refresh()` loop runs only while something is in-flight and only while the tab is visible. Manual controls (kept even in autonomous mode): "Pull now" (`/api/admin/seo/pull-trends`), per-row/bulk **Author** (`/api/admin/seo/author` → `dispatchAuthorBatch`), and Reject/Reset (`/api/admin/seo/candidate-status`).

**Copy discipline:** keep the UI strings accurate to the actual cadence + autonomy (e.g. "runs twice daily 9 AM & 5 PM ET, auto-authors every fresh candidate") — stale "runs daily at 13:00 UTC" copy misleads.

---

## 11. Environment variables (the matrix that bites)

The #1 setup failure is putting a var in Vercel but not Trigger.dev (or vice-versa). Workers run on **Trigger.dev**; routes run on **Vercel**. They have separate env stores.

| Var | Vercel (routes) | Trigger.dev (workers) | Purpose |
|---|---|---|---|
| `GITHUB_DISPATCH_TOKEN` | ✅ (manual Author button) | ✅ (autonomous auto-author) | PAT (`repo`+`workflow` scope) to fire the Action. **Needed in BOTH.** Mark it **Encrypted, not Sensitive**, in Vercel so it stays readable for future syncs. |
| `SEO_AUTHOR_CALLBACK_SECRET` | ✅ (callback route validates) | — (Action holds it as a GH secret) | Authenticates Action → app callbacks. |
| LLM extraction key (e.g. OpenAI) | — | ✅ | Entity classification in `poll-trends`. |
| `GOOGLE_GSC_SA_JSON_B64`, `GSC_PROPERTY` | — | ✅ | `sync-gsc` worker. |
| `BING_WEBMASTER_API_KEY` | — | ✅ | `sync-bing` worker. |
| `REDDIT_CLIENT_ID/SECRET`, `PRODUCT_HUNT_API_KEY` | — | ✅ | Key-gated discovery sources (optional). |
| Supabase `SERVICE_ROLE_KEY` + URL | ✅ | ✅ | Service-role repo writes everywhere. |
| `CLAUDE_CODE_OAUTH_TOKEN` | — | — (GitHub secret) | The AI author, inside the Action. |

GitHub secrets (repo settings): `CLAUDE_CODE_OAUTH_TOKEN`, `SEO_AUTHOR_CALLBACK_SECRET`. **Don't bake a token into the git remote URL** — when you rotate it, `git push` breaks; use `gh auth setup-git` / a credential helper instead.

---

## 12. Build sequence (vertical slices — pause after each)

1. **Schema + repo** — the migration (§3, full consolidated) + `seo-engine.ts` types/CRUD + `gradeTrendPage`. Nothing user-facing yet.
2. **Analytics tab (read-only)** — `seo-geo-regrade.ts` + `npm run seo:sync-grades` to populate `seo_pages`; the Analytics view rendering cards/rollup/table from `getAuditPages()`; empty-state stamps for traction/index. Ships the audit you check daily, in-app.
3. **Layer B sync** — `sync-gsc` + `sync-bing` workers + `seo_sync_runs` + "Pull now" + IndexNow. Traction + index light up.
4. **Discovery (read-only)** — `trend-sources.ts` + `poll-trends` (discover + classify + insert, NO auto-author yet) + the Discovery tab + manual Reject. Starts the daily radar; generates gate-training labels.
5. **Authoring loop (gated)** — `author-dispatch.ts` + the GitHub Action + the callback route + the public render model (§7). Manual "Author selected" button only. End-to-end trend-jacking, human-gated.
6. **Autonomy** — flip `poll-trends` to call `dispatchAuthorBatch` on every new candidate. Earn this: keep the human gate until a few weeks of approve/reject labels show the classifier + author quality is trustworthy, then drop the gate. Add the auto-noindex monitor (any authored page still `crawled_not_indexed` after 14 days → flag/archive so thin pages don't accumulate).

---

## 13. Gotchas & lab notes (portable lessons)

- **The two hard ceilings on candidates/day are the per-kind caps and `max_output_tokens`.** Caps clip the count; a too-low token limit truncates the LLM's JSON array tail so candidates vanish before the cap even applies. Both bit BKE (10/8 caps + 1800 tokens → ~16/day; raised to 20/15 + 6000 → ~30+/day).
- **`GITHUB_DISPATCH_TOKEN` must live in BOTH Vercel and Trigger.dev.** The manual button reads Vercel's copy; the worker reads Trigger's. A redeploy does NOT inject it — env vars are set in each platform's dashboard/API, separate from the code bundle.
- **Vercel "Sensitive" env vars are write-only** — you can't read them back via CLI/API/pull (returns empty), only the runtime sees them. Mark shared secrets **Encrypted** if you'll need to copy them to another platform later. (You can set them as `encrypted` via the Vercel API `type` field; the CLI's `env add` defaults to sensitive.)
- **`ignoreDuplicates` returns only new rows** — that's what makes "auto-author only the new ones" correct, and what makes a rejected candidate stay rejected across re-discovery. Don't switch it to a real upsert.
- **PostgREST caps at 1000 rows** regardless of `.limit()` — paginate index/traction reads with `.range()` (`selectAll<T>()`).
- **The public render route must be headers-free** if it reads the DB (Model B) — `next/headers` forces dynamic rendering and destroys the static HTML. Use a plain service-role reader. (Moot in Model A — pages are SSG from data modules.)
- **Serialize the author Action** (`max-parallel: 1`) — parallel legs editing the same data module race and drop pages; each leg must checkout the latest tip.
- **Callback route needs an edge-auth allowlist entry** — it's session-less (secret-authed); a default-deny `/api/*` proxy 401s it before the secret check.
- **Never overwrite a real index status with `unknown`** on a transient API throttle — return `null` and skip the write (monotonic counts).
- **Don't hardcode page totals** in Analytics — query live; the moment the first authored page ships, "635 pages" is a lie.
- **Authored content is "compliant by construction" only if it's original + timely + product-bridged.** The fact-check pass + the required product bridge (`bridge_ok`) are what keep volume from tipping into "scaled content abuse." Keep the daily cap sane and the auto-noindex monitor on.

---

## 14. Per-app customization checklist

When invoking this for a new app, swap these (everything else is mechanical):
- [ ] **Collections** — define the app's page-types + their data modules + public routes (from TOPICAL-AUTHORITY). Update the classifier prompt's taxonomy + `AUTHOR_COLLECTIONS` + `getExistingSlugsByCollection()`.
- [ ] **The product bridge** — the paragraph every page must contain tying the trend to your product. This is the GEO/conversion hook and the `bridge_ok` gate.
- [ ] **Source catalog** (`trend-sources.ts`) — the app's competitor list, Google News queries, niche RSS feeds, subreddits. The whole "what entity-space do we watch" decision lives here.
- [ ] **LLM extraction prompts** — Pass A/B framing for the app's niche (what's a "tool" vs "competitor" vs "topic" here).
- [ ] **Caps + cadence** — candidates/day target → set `MAX_*` caps + cron frequency + `max_output_tokens`.
- [ ] **Credentials** — Supabase, Trigger.dev project, GitHub repo + `author-trend-page.yml` (swap the repo owner/name in `author-dispatch.ts`), GSC service account + property, Bing key, IndexNow key file.
- [ ] **The AI-author prompt** in the Action — point it at the app's content specs + capabilities doc + per-collection spec.

---

## 15. Kickoff prompt ("run this template")

Hand a Claude Code agent this, with the app's inputs filled in:

> Build the **SEO & GEO Engine** in this app per `templates/a-tier/SEO-GEO-ENGINE-PLAYBOOK.md`. Reference implementation: BKE at `Active/BILT-KONTENT-ENGINE/` — read those files rather than re-deriving.
>
> **App inputs:**
> - Collections: `<list>` (data modules + public routes already exist per TOPICAL-AUTHORITY — confirm first)
> - Niche to watch: `<entity space>` · Competitors: `<list>` · Topic queries: `<list>`
> - Product bridge: `<the one paragraph every page must contain>`
> - Supabase project: `<ref>` · Trigger.dev project: `<ref>` · GitHub repo: `<owner/repo>`
> - Discovery cadence + caps: `<e.g. twice daily, 20 tool / 15 intel>`
>
> Build in the §12 slice order, **pausing after each slice for review**. Slice 1 (schema + repo) first. For each slice: read the corresponding BKE reference files, adapt (don't blindly copy) names/collections/sources to this app, ship the migration via the Management API (never `db push`), typecheck with the local `tsc` (not `npx tsc`), and confirm env vars are set in BOTH Vercel and Trigger.dev where §11 requires. Do not enable autonomy (slice 6) until I say so — keep the human gate until the classifier + author quality is proven.

---

**Built:** 2026-06-23. **Reference implementation:** BKE / Kompozy (autonomous SEO/GEO engine, twice-daily discovery + AI auto-authoring, 9 collections). **Tier rationale:** A-tier — high-leverage for content/marketing apps, layered on S-tier infra (admin console, Trigger.dev, topical-authority pages); not every app needs it, but every content app that wants to grow its surface autonomously does.
