CLAUDE.md
August 20, 2026 · View on GitHub
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
DispatchSEO — a single-owner, multi-tenant "SEO manager" backend. One Vercel
deployment manages many sites: it exposes an MCP server that an external
Claude Code agent drives to research keywords and queue content ideas, runs
crons that track SERP ranks + Google Search Console stats, and hosts a
password-gated dashboard to approve ideas and merge the resulting PRs. The
agent does the thinking; this backend is state + scheduling + the door to that
state. See docs/SPEC.md for the original spec (phase history and launch
planning live in the maintainer's untracked docs-private/).
Commands
Package manager is pnpm. There is no lint or test script — pnpm build
(which runs tsc via next build) is the type/build check.
pnpm dev # next dev on localhost:3000
pnpm build # production build + typecheck (use this to verify changes)
pnpm start # serve the production build
# one-off maintenance scripts (require .env.local loaded), e.g.:
node scripts/backfill-gsc.mjs
node scripts/gate-test.mjs # smoke-tests the MCP bearer gate
Env lives in .env.local (template: .env.local.example). The production app
is served at dispatchseo.com (custom domain). The old
seo-manager-backend.vercel.app alias no longer resolves (returns 404), so any
external caller — GitHub Actions crons especially — must target dispatchseo.com.
The Vercel project may still be internally named seo-manager-backend; that is
just the dashboard label and does not affect the public URL.
Architecture
Stack: Next.js 16 App Router · React 19 · Tailwind v4 (@tailwindcss/postcss,
no config file) · Supabase (service-role) · mcp-handler + @modelcontextprotocol/sdk
· DataForSEO + googleapis (GSC). Path alias @/* → ./src/*.
The tenant axis (read this first)
src/lib/projects.ts is the single place that answers "which site". Every
operational table carries a project_id; the three entry points each resolve a
project a different way, then scope every query to it:
| Entry point | Resolves project via | Code |
|---|---|---|
| Dashboard | dash_project cookie → slug | active-project.ts → getActiveProject() |
| MCP server | bearer token IS the tenant | getProjectByToken() |
| Crons | loop over listProjects() | each cron route |
The default project ClockedCode has a fixed id
00000000-0000-4000-8000-000000000001, which is also the column default on
every table, so pre-multi-tenant writes still land somewhere valid. When a
Supabase query errors (e.g. a migration hasn't run yet), projects.ts
synthesizes an env-fallback ClockedCode project so the deploy keeps working —
site_profile and playbook_status use the same tolerance pattern. The legacy
MCP_API_KEY env token keeps resolving to ClockedCode so existing CI secrets
never need rotation.
MCP server — src/app/api/[transport]/route.ts
- Lives at
api/[transport]withbasePath: "/api", so the connectable URL is/api/mcp(transport =mcp). Streamable HTTP (no SSE, no Redis). authed()wraps the handler: extracts theBearertoken →getProjectByToken→ runsmcpHandlerinsideprojectStore.run(project, …). Tools readcurrentProject()from thatAsyncLocalStorage(mcp-context.ts) instead of threading a project param throughmcp-handler's registration API.- The MCP is ONLY a door to Supabase state — the suggestions queue, keywords, pages, GSC stats, backlink prospects, site profile. It does NOT call DataForSEO or generate content. The agent reasons and uses DataForSEO's own MCP for raw research. Don't add research/generation tools here.
- Exception that proves the rule:
get_instructionsserves the centrally-versioned agent playbook (src/lib/instructions/) — content-as-state, likeget_playbook. Connected repos keep only a thin shim (GitHub workflows + a.dispatchseo/conventions.mdof site facts that thesetupworkflow writes); every automation fetches its instructions from this tool before acting, so editingsrc/lib/instructions/updates every project's next run. BumpINSTRUCTIONS_VERSIONon every meaningful edit; smoke-test withnode --env-file=.env.local scripts/mcp-instructions-test.mjs(dev server up). - The dashboard is fully controllable via MCP, and stays that way — every new dashboard capability needs a matching tool here. See the parity rule under Conventions & gotchas.
- Tools return pretty-printed JSON text via the
ok()/fail()helpers. - Approving a suggestion of type
toolfires arepository_dispatchto wake the project repo's builder workflow immediately (dispatchToolBuild); guides wait for the daily cron.
Data layer — src/lib/db.ts
db() returns a cached service-role Supabase client that bypasses RLS.
There is no user auth — every caller is trusted server code (MCP tools + crons).
Tables have RLS enabled with zero policies, so only the service-role key can
touch them. Never import db.ts into anything that ships to the browser.
Auth (single user, gated at the edge)
- Dashboard: password gate in
dashboard-auth.ts. Login is a server action that sets an HMAC-of-a-fixed-message cookie (dash_auth) keyed byDASHBOARD_PASSWORD; changing the password invalidates all sessions. There is nomiddleware.ts— its Next 16 successor issrc/proxy.ts, which gates every non-allowlisted path on cookie presence (dash_auth, or a Supabasesb-*-auth-tokeninCLOUD_MODE) and 307s the rest to/login. Presence is only routing: every protected page still checksisValidCookie(jar.get("dash_auth")?.value)itself andredirect("/login"), so a forged cookie renders nothing. New dashboard pages must add that guard — and any new route a logged-out visitor must reach (a public page, a script, an image) has to be added tosrc/proxy.ts's allowlist or it 307s to/loginin production. Self-host rarely notices (noLANDING_ENABLED); cloud always does. - Crons:
checkCron()requiresAuthorization: Bearer ${CRON_SECRET}. - MCP: per-project
mcp_token(or legacyMCP_API_KEY→ ClockedCode).
Crons — split across two schedulers
Vercel Hobby caps crons at once/day and 2 jobs total, so schedules are split:
vercel.jsonruns only daily-ranks (0 4 * * *).- Higher-frequency crons live in
.github/workflows/*.yml, whichcurlthe backend cron endpoints withCRON_SECRET(e.g.hourly-gsc.ymlat:07). - Routes:
src/app/api/cron/{daily-ranks,hourly-gsc,weekly-opportunities,seo-dispatch,serp-collect,jobs,heartbeat,deploy-check}/route.ts(self-host schedules live indocker/cron/crontab, whose header documents which routes are deliberately excluded there and why).
Every cron loops all projects and isolates failures with
Promise.allSettled — one project or one half (SERP vs GSC) failing must not
kill the rest — then returns HTTP 500 if anything failed so the Vercel run
log surfaces it. Every route also calls reportCronRun() (cron-alerts.ts):
runs log to cron_runs, failures show on the dashboard Home banner and the
get_cron_health MCP tool, and email the owner via Resend (debounced per job).
One deliberate exception: heartbeat is a single global ping to
dispatchseo.com (anonymous self-host install count), not a per-project loop,
and skips reportCronRun() on purpose — a failed heartbeat costs this project
one data point and costs the owner nothing, so it must never reach the
banner/email rail (src/lib/heartbeat.ts).
Post-deploy smoke test: every push to main triggers
.github/workflows/deploy-check.yml, which polls
/api/cron/deploy-check?expect=<sha> until Vercel serves the pushed commit,
lets the endpoint self-check its internals (core tables, project resolution,
GSC creds), then probes /login, the MCP gate (tokenless → 401 AND valid key
→ 200) from outside. Failures ride the same cron_runs → banner → email
rails, so a broken deploy announces itself instead of surfacing as morning
cron errors.
Workflow outcome reporting + secrets canary: the SEO workflows
(seo-daily, seo-auto-merge, seo-tools, seo-trend-scan, seo-trend-expand,
seo-weekly-research) end with a "Report outcome to the dashboard" step that
calls /api/cron/deploy-check?job=<name>&ok=1|&fail=<msg> — workflow
failures hit the banner + email instead of dying quietly in the Actions tab
(seo-tool-validate reports from its MERGE job only: the validate job runs
LLM-authored PR code and holds no secrets at all). secrets-canary.yml runs
every 6h validating CRON_SECRET, the Claude token's shape (the line-wrapped
paste gotcha), and that the backend accepts SEO_MCP_API_KEY — so a rotted
secret is flagged hours before the overnight builders die on it. Any new
scheduled workflow should add the same report step and, if relevant, a
STALE_HOURS entry in cron-alerts.ts.
DataForSEO (dataforseo.ts) & GSC (gsc.ts)
Free-tier DIY: each project brings its own DataForSEO account, so every call
bills the project owner. credsForProject() resolves a project's creds; only
the default project falls back to DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD env.
A project without creds gets null and paid features skip gracefully. GSC uses
a service-account JSON (GSC_SERVICE_ACCOUNT_JSON); it's free, hence the hourly
re-snapshot.
Conventions & gotchas
- Crons never run what setup hasn't finished. Every per-project capability
a cron touches must pass a readiness check first (pattern:
src/lib/gsc-readiness.ts+ the setup gates at the top of each cron route'srunProject). Unmet setup returns{ skipped: "setup incomplete: …" }— informational, neverhadError/HTTP 500/alert email — because onboarding deliberately leaves projects half-set-up (it even guesses a GSC property before the owner grants access), so "prerequisite missing" is a normal state, and the Home "Initial setup" cards are the user-facing surface for it. Loudness is reserved for regressions: a capability that has verifiably worked before (data rows exist / creds validated at save time) and fails now must keep failing loudly (banner + email). Any new cron, or new per-project prerequisite in an existing cron, must implement this split — never let a mid-setup project 500 a run. - Every feature ships with an MCP version. The dashboard and the MCP server
are two faces of the same state — anything the dashboard can do, the agent must
be able to do over MCP, and vice versa. A feature is not done until both exist.
In practice: put the logic in a
src/lib/module, then call it from the dashboard server action and register the matching MCP tool insrc/app/api/[transport]/route.ts— never implement it inside the server action and leave MCP behind. This does not license research/generation tools (see the MCP section); the parity rule is about state — reads, writes, approvals, ordering, config. If a surface genuinely can't have a counterpart (e.g. a purely visual chart, or operator-only telemetry with no per-tenant state and nothing an agent should read or set, like the self-host heartbeat), say so in the PR rather than skipping quietly. The mirror of this rule: a tool that exists must be listed insrc/content/docs/mcp-tools.mdx— an undocumented tool is one the agent never learns it has. - Migrations (
supabase/migrations/NNNN_*.sql) are numbered, additive, and zero-downtime — new columns useADD COLUMN … DEFAULT <clockedcode id>so in-flight code keeps writing valid rows. Add a new numbered file rather than editing an existing one; they are applied to Supabase manually (no migration runner wired into the build). - Every migration must also apply on vanilla Postgres — the docker stack
replays the concatenated
setup.sqlon every boot. Supabase-only objects (theauthschema,auth.uid(), storage) must be guarded in aDOblock that checks the object exists (pattern:0031_cloud_users.sql); an unguarded reference breaks every self-host install at first boot. After any migration change, runnode scripts/generate-setup-sql.mjsand commit the regeneratedsetup.sql— CI (migrations-vanilla-postgres.yml) applies it twice againstpostgres:17-alpineand fails the push otherwise. - Nothing in
templates/pipeline/may depend on an install-time edit whose omission is fatal. The installer is an agent adapting an unfamiliar repo, so "usually adapts it correctly" is not a safety property when the failure mode is a permanently dead pipeline. Encode the decision in the workflow so it resolves at run time in the repo (two mutually-exclusive steps withif:conditions), rather than leaving anINSTALL-ADAPT: … DELETE this linecomment. The rule exists because that exact comment on the pnpm setup step killed the builder in every installed repo carrying apackageManagerpin on 2026-07-30 — at step 2, before the step that reports failures, so it died silently as well.scripts/pipeline-pack-lint.mjs(apr-checkgate) fails the build on that shape, andpr-check'spnpm-setup-shapesjob executes the shipped setup path against a pinned and an unpinned fixture, because a template nobody ever runs is first run by customers. - Any new operational table needs a
project_id(default the ClockedCode id) and its uniqueness constraints scoped per-project — see0004_projects.sql. - Server-only modules (
db.ts, anything touching the service role or secrets) must never end up in a client component bundle. - User-visible work gets a changelog line; only a release cut announces it.
Append the line to
UNRELEASEDinsrc/lib/changelog.ts(owner's language, not the commit subject) and stop there — that list is invisible on purpose. Cutting a release is a separate, deliberate act, and only ever on the maintainer's say-so: the staged lines move into a newCHANGELOGentry with a semverversion, a title and a summary, andUNRELEASEDempties. Never cut one unprompted — that entry hits the/changelogpage, theget_changelogMCP tool and a permanent post in the public Discord, all at once. The dashboard "DispatchSEO has been updated" banner is a separate, rarer opt-in: it fires only when the entry setsannounce: true, which is the maintainer's call alone — default is silent, reserved for a release owners should stop and look at. Six releases went out on 2026-07-31 alone, which is what versions exist to stop; a day of work is one release, and a quiet week is fine. Bump minor for anything new, patch for fixes, major only for something a self-hoster must act on. Versions are anchor ids, cookie values and Discord deep links: never reuse, renumber or reorder one. Internal-only work ships without a line at all. - Deferred/out-of-scope ideas go in
LATER.md, not into code. The ethos is "working > pretty", single-user, no over-engineering.