Cory Copeland
Back to work

SEO/GEO Agent

Active

A technical auditing engine for search and generative-engine visibility, live as a public self-serve web app at audit.corycopeland.dev — free scoring, accounts, delta tracking, and a tested Stripe billing layer staged for cutover — plus its own CLI-driven lead-generation and outreach engine. Every scoring rule now has to clear a release-gate corpus in CI before it counts as shipped.

  • Automation
  • SEO
  • GEO
  • Crawling
  • Structured data
  • SaaS

Updated 2026-08-31

SEO/GEO Agent

Overview

The SEO/GEO Agent started as a free-by-default audit toolkit for classic SEO and Generative Engine Optimization — visibility in ChatGPT, Perplexity, and Google AI Overviews. A pnpm monorepo with a polite, robots-aware crawler, a JS-render fallback through Puppeteer, seven collectors across SEO and GEO signals, deterministic scoring, and three Claude Code skills (/seo-audit, /geo-audit, /full-audit).

That core engine is still there and still the primary interface for batch audits, agency-branded reports, and cold outreach. But it's since grown two more surfaces on top: a live self-serve web app at audit.corycopeland.dev — built in four staged sub-projects (SP1–SP4) — and its own independent lead-generation and outreach engine, complete with a leads ledger and an email drafter. The web app now carries the "Cory Copeland — AI Business Systems" brand lockup: it's deployed and positioned as ABS's free top-of-funnel audit tool, even though the two codebases share no code.

The work since has gone into the part that decides whether any of it is trustworthy: the rule set itself. Rules are now gated by a corpus that has to pass in CI before a rule counts as in-scope, and the headless browser that renders JS-heavy pages sits behind a proxy that pins every connection to a vetted address.

The problem

Two adjacent disciplines, no good single tool:

  • Classic SEO — title/description quality, headings, schema, robots, performance, content depth, keyword coverage. A dozen paid SaaS products cover this for marketing teams, but each is rented, opinionated, and hostile to scripting.
  • GEO (Generative Engine Optimization) — citability in AI search: whether the page is rendered without JavaScript, whether it has structured data the model can cite, whether the brand has external authority a model can ground against. Practically no off-the-shelf tooling exists yet.

The opportunity was a single toolkit that runs both passes against any URL, writes deterministic reports an LLM can summarize cleanly, and stays free for the operator by default. Paid providers (DataForSEO, real AI-platform citation checking) are stubbed unless free-tier limits actually bite.

A second problem showed up once the toolkit worked well: a CLI is a great primitive and a terrible pitch. Local-service businesses — the exact audience the audit is most useful for — don't run pnpm audit-cli. They need a URL to paste their site into and a reason to hand over an email address.

Audience

  • Operators running multiple brands who need a repeatable audit workflow that doesn't depend on a paid dashboard.
  • Developers who want the audit primitives composable, not locked behind a UI — the CLI emits structured JSON; Claude writes the prose.
  • Small local-service businesses — roofers, med spas, chiropractors, cleaning services — who'll never touch the CLI but will paste a URL into a free web form, and who are the actual outreach targets of the toolkit's own lead-generation engine.

What I built

The core engine — a pnpm monorepo, unchanged in shape since Phase 1:

  • @seo-agent/core — collectors (SEO technical, SEO content, SEO keywords; GEO citability, GEO schema, GEO technical, GEO authority), a polite fetcher (robots-aware, disk-cached, retry), a BFS crawler, a Puppeteer JS-render fallback, scoring, and the orchestrator.
  • @seo-agent/render — markdown, PDF, and one-page cold-outreach teaser renderers, plus white-label agency branding on client-facing PDFs.
  • @seo-agent/cli — the audit-cli binary: audit, compare (before/after delta reports), recheck (scheduled re-audits with auto-delta), and — newer — leads and outreach, a self-sourced lead ledger and cold-email drafter for the exact local-service niches the tool is built to audit.
  • Three skills: /seo-audit, /geo-audit, /full-audit — still the fastest way to run this from inside a Claude Code session.

A self-serve web app, built in four staged sub-projects (SP1–SP4), all live at audit.corycopeland.dev:

  • SP1 — audit service + public page. Paste a URL, get an SSRF/rate-limit/dedupe-guarded audit, watch a results page poll to completion, and trade an email for the full PDF. No signup required for the free score.
  • SP2 — accounts + history. Magic-link auth, owner-scoped saved sites, per-site audit history, and a before/after delta compare view.
  • SP3 — billing + plans. A full Stripe Checkout/Customer Portal integration, Free (3 saved sites, on-demand) vs. Pro (~$29/mo, 5 monitored sites with scheduled recheck + delta email alerts) — built and tested end to end, but not switched on in production yet (the Stripe keys are intentionally unset).
  • SP4 — ops integration, merged today: an admin view of job/lead metrics, and a token-authed export/ack protocol that a nightly cron uses to pull web-app leads into the same ledger the CLI's outreach drafter reads from.

The web app carries the "Cory Copeland — AI Business Systems" brand lockup — it's deployed and positioned as ABS's free top-of-funnel audit tool, even though it shares no code with the separate ai-business-systems-demos / ai-systems-builder repos.

Invocation, still, is either through Claude Code directly:

/full-audit https://spiceshelf.app
/seo-audit https://kingrove.corycopeland.dev --pdf
/geo-audit https://example.com --explain

…or through the CLI binary, or now through the public web form.

Product decisions

  • Deterministic data, LLM-written prose. The CLI's job is to be boring and correct — emit the same report.json for the same URL every run. The skill's job is to be readable. Mixing those produces unreproducible outputs and brittle scoring.
  • Free by default; paid providers are stubs. DataForSEO and real AI-platform citation checking are stubbed in packages/core/src/providers/ and wire in only if the free path proves insufficient.
  • The web app is a second surface, not a replacement. The CLI/skills remain the primary interface for batch audits, agency branding, and outreach; the web app is a self-serve funnel built on the same engine for people who'll never touch a terminal.
  • Build the paywall before you need it, but don't flip it on before you do. SP3 shipped a fully tested, provider-abstracted billing layer (BillingProvider interface, Stripe implementation plus a fake for tests) months before the first Pro subscriber — but the production Stripe keys stay unset until there's an actual reason to charge someone.
  • SSRF guarding is a first-class module, not an afterthought. A dedicated net-guard package does default-deny DNS resolution, per-hop redirect re-validation on every fetch, explicit 6to4/Teredo/fe80::/10 blocking, and a browser subresource-interception policy — because this tool's whole job is fetching arbitrary URLs strangers paste into a public form.
  • The web app has its own lead-generation twin, deliberately kept separate. The CLI's leads/outreach commands source and pitch local service businesses directly (flat-fee fix + retainer), independent of whoever finds the tool through the web form. SP4's nightly sync converges both into one ledger without merging the systems that produce them.
  • Self-healing sync over perfect sync. SP4's export→import→ack cursor only advances after a successful import, so a failed cron run automatically re-fetches the same batch the next night instead of losing leads or needing manual recovery.

Technical architecture

  • Language / runtime: Node 20+, TypeScript, pnpm monorepo, SQLite (better-sqlite3, WAL mode) as the datastore across both the CLI ledger and the web app.
  • Crawler / collectors: unchanged from Phase 1 — BFS, robots-aware, disk-cached, Puppeteer JS-render fallback, seven independently testable collectors.
  • Web app deployment: two Docker containers (web — never touches Chromium; worker — runs the audit with system Chromium) sharing a /data volume, on a dedicated LXC (deployment-pve, VMID 221) behind a Cloudflare Tunnel, web bound to 127.0.0.1:3000 only.
  • Queue: rate-limit → canonicalize + SSRF-validate → dedupe-within-window → queue-cap busy path (email-gated) → enqueue — one composed pipeline, five independently tested outcomes.
  • Billing: idempotent, signature-verified Stripe webhook state machine with a stripe_events ledger for exactly-once processing and client_reference_id binding so a completed checkout can't grant Pro without a confirmed subscription.
  • Scheduler: claim + enqueue + cursor-advance wrapped in a single SQLite transaction, after a Codex review caught that a naive scheduler tick could crash mid-cycle and double-enqueue a recheck.
  • Render isolation: the Chromium render and screenshot paths run behind a pinning forward proxy. SSRF-validating a URL before handing it to a browser is not enough — the browser resolves DNS itself and follows its own redirects, so a hostname that vetted clean can still land on an internal address a moment later. The proxy vets each CONNECT target, pins the connection to the vetted IP, fails over across the remaining vetted addresses if one refuses, and tears the upstream socket down when the client aborts mid-vetting.
  • Release gate: a release-gate CLI command runs the rule set against a real multi-page corpus as a hard CI gate. A rule is only "in-scope" once it fires correctly on a corpus case — 26 rules currently clear that bar. It is the mechanism that let seo.missing-robots be retired on the grounds that absence of a robots.txt is not a defect: the corpus made the claim testable rather than arguable.
  • Providers: MailProvider (Resend) and BillingProvider (Stripe) are both interface-abstracted with fake implementations for tests, so the entire notification/billing surface is unit-testable without live keys.

Real-world calibration

Phase 1's scoring thresholds were originally calibrated against the plan's own test fixtures. After dogfooding against three brands — Spiceshelf 63, SetOff 55, KinGrove 49 — geo-citability was restructured into geo.no-static-render (empty raw HTML, JS render saves it) and geo.thin-content-rendered (sparse even after JS render). The trailing-slash duplicate-title bug from the original TODO list is fixed (canonicalize before dedupe). The geo-schema per-type weight is still a flat, uncalibrated guess — genuinely still open.

Rule coverage has since widened on the same evidence-first footing: schema detection reads Microdata and RDFa alongside JSON-LD; og:type maps to its expected schema.org types with full subtype families, so a article page carrying NewsArticle is not flagged as a mismatch; local.nap-mismatch fires on a structured-data identity conflict rather than fuzzy string comparison; and a 5xx on robots.txt or sitemap.xml is now its own finding instead of being swallowed as an absence.

Current status

  • Core engine (CLI + 3 skills): live, unchanged in shape, still the primary interface for batch/agency work and the tool behind the CLI's own lead-generation and outreach engine.
  • Web app (SP1, SP2, SP4): live at audit.corycopeland.dev — the public free-audit funnel works end to end.
  • SP3 billing: built, tested, dark. Accounts, the dashboard, and billing are all currently blocked in production because the Stripe environment variables are intentionally unset — login 500s until that flips on (tracked as a repo issue).
  • SP4 admin view: shipped but not yet reachable for the same reason (needs a logged-in admin); the export/ack sync endpoints it also shipped are token-authed and run regardless. A staged test-to-live Stripe cutover helper now exists, so flipping it on is a rehearsed step rather than an improvised one.
  • Release gate live at v0.9.0 — 26 rules in-scope against the real corpus, with render diagnostics and telemetry recorded per URL so a low-scoring audit can be traced to whether the page actually rendered.
  • Outreach scores its own leads. The web app's outreach tab counts pitchable findings per lead, badges them by pitch strength, and sorts by it — so cold outreach starts from the prospects where the audit found something real to say.

What I would do next

  • Flip on the Stripe keys and take the SP3 billing layer live — it's already built and tested, just not turned on.
  • Calibrate geo-schema weights against more real brand data.
  • Put /data on a backup cron — currently the one real infra gap on the new deployment.
  • Decide whether the web app's self-service leads and the CLI's cold-outreach leads should ever share a single outreach cadence, or stay deliberately separate funnels feeding the same ledger.

Proof

  • Live web app: audit.corycopeland.dev
  • Repo: spirix/seo-agent on self-hosted Forgejo.
  • Live skills: /seo-audit, /geo-audit, /full-audit available in Claude Code on the dev environment.
  • The hero image on this page is a live capture of the audit web app.