Architecture

Pure cores, thin shells, the adapter seams and the data flow.

Shape: pure cores + thin shells

  • core/ — zero-dependency ESM functions, no I/O. Pure logic (parsing, scoring,

budget math, breaker state, SSRF checks). Tested with node --test. Portable to Node, Deno, or the browser.

  • backend/ — orchestration "researchers" that take (input, deps) where every

side-effect (search, fetch, callClaude, DB writes) is an injected dependency. No direct I/O; trivially faked in tests.

  • adapter/ — the swappable provider implementations (SERP, web search, Search

Console, fetch) plus the content-adapter contract.

  • db/ — the seo_* schema (schema.sql + schema.v2.sql) and rollbacks.
  • The edge shells (seo-generate, seo-worker) — thin Deno wrappers that build the

deps from Supabase + your keys and call the backend. This is the only project-specific glue.

Two reuse seams

1. Content adapter — listIndexableEntities(): Promise<SeoEntity[]>.

   interface SeoEntity { urlPath: string; title: string; summary?: string; entityType: string; updatedAt?: string }

2. Config — seo_settings (+ seo.config.json): providers, budget cap, cost_rates, enabled researchers, cron cadence.

Everything else is generic. Porting to a new project = implement seam 1 + set seam 2.

Data flow

listIndexableEntities ─┐
                       ├─► seo-generate ("Update SEO") ─► profile + proposals (scored) ─► admin review ─► approve/reject
docs / project context ┘                                                                        │
                                                                                                ▼
seo-worker (cron loop):                                                                    seo_feedback
  enqueue → drain jobs:                                                                    (learning signal)
    rank_sweep  ──► SERP positions ──► "not #1" proposals                                        │
    discover    ──► outranking domains ──► seo_competitors                                       ▼
    analyze_competitor ──► fetch (SSRF-guarded) → LLM analysis → gap proposals          proposalRanker
    research_site ──► content knowledge → proposals                                     (orders inbox,
  selfcheck ──► health checks (halt-on-regression)                                       drops rejected types)
  record_outcomes ──► rank movement → seo_feedback (rank_win/loss)

Key module signatures

  • makeBudgetGate({ spentCents, capCents, rates }) → { canSpend(kind), record(kind), remaining(), spent() }
  • runRankMonitor({ keywords, own }, { serp, budget, saveRank, insertProposals, recordSpend })
  • runCompetitorResearch({ competitor, own }, { webSearch, fetchPage, budget, callClaude, saveAnalysis, insertProposals, recordSpend })
  • runContentResearch(input, { callClaude, saveKnowledge, insertProposals })
  • drainJobs({ limit, spentCents, capCents }, { claimJobs, handlers, markDone, markFailed, markCapped })
  • rankProposals(proposals, feedbackByType) — filter down-weighted types + score + sort
  • attributeRankOutcomes(keywordRows, approvedProposals) → { proposal_type, outcome }[]
  • withCircuit(target, fn, { getState, saveState, onSkip })
  • gscProvider({ token, siteUrl }) → { positions() }; serperProvider({ apiKey }) / googleProvider({ apiKey, cx }) → { search() }