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/— theseo_*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 + sortattributeRankOutcomes(keywordRows, approvedProposals)→{ proposal_type, outcome }[]withCircuit(target, fn, { getState, saveState, onSkip })gscProvider({ token, siteUrl })→{ positions() };serperProvider({ apiKey })/googleProvider({ apiKey, cx })→{ search() }