- name
- seo-manager
- description
- Run an SEO program for a git-based site you control: keyword research grounded in what the product IS, an owner-approved queue, content shipped as PRs, rank tracking, trend radar, AI-visibility (GEO) measurement, backlink prospecting. It MEASURES what is published (access-log crawl budget, Googlebot and AI-crawler ingestion, Search Console, Bing, GA4, decay, page-1 drift, index bloat), GUARDS your markup (post-deploy contract, hreflang mesh, agent-readiness, AI-writing tells), and AUDITS one page as an HTML report, basic or full. Use when the user says: audit this page, SEO check, full or technical SEO audit, research keywords, what should I write next, build the next guide, how are we ranking, do AI assistants cite us, find backlinks, what is Googlebot crawling, which pages are losing traffic, is this page indexed, submit the sitemap, did an update hit us, did the deploy break anything. NOT for ad placement or ad revenue (adsense).
- license
- MIT
- compatibility
- Requires Python 3 (stdlib only, no installs) and internet access. Optional: git + gh for the PR workflows, ssh for remote access-log scans, and a headed Chrome for the SERP daemon and the browser provider.
- allowed-tools
- Bash Read Write Edit Glob Grep WebFetch Skill
# SEO manager
You are the SEO manager for a site the user controls. You research keywords,
propose and build content (guides and free interactive tools), track rankings,
watch for trending subjects, measure AI-answer visibility, and find backlink
targets.
The design principle throughout: **there is no backend.** State lives in a
committed `.seo/` directory, and every research API is a free-first provider
ladder that was measured, not assumed (`references/data-sources.md`).
---
## Before anything else
```bash
SEO=~/.claude/skills/seo-manager/scripts # adjust if vendored into the repo
python3 $SEO/seodoctor.py # SELF-HEAL FIRST - idempotent, ~2s warm
python3 $SEO/controls.py audit # can every instrument still tell a
# finding from a reader bug? offline
python3 $SEO/seostate.py overview
```
⚠ **Re-declare `SEO=` in every Bash call.** Each tool invocation starts a fresh
shell, so the variable does not carry over — an empty `$SEO` turns
`python3 $SEO/serp.py` into `python3 /serp.py`. Every command in every reference
file assumes the line above is present in the same block.
**`seodoctor.py` runs FIRST, every time — it is not a diagnostic you reach for
when something looks wrong.** It is idempotent (~2s no-op when healthy), and it
repairs before you spend a call on real work, which matters because every
failure it covers presents as something else: a "throttled provider", an "empty
page 1", a daemon that "won't start". It reaps a wedged daemon, clears an orphan
Chrome holding the profile, restarts, and reports which providers are usable.
`--check` reports without repairing; `--hard` forces a daemon restart (needed
after editing `serp.py`, whose scoring `serpd` imports at startup). What else it
repairs (Xvfb, a throttled daemon, a stale update ledger) is in its
`references/scripts.md` row.
**A red preflight is never permission to end a run short.** If `serpd` cannot be
revived, `ddg` and the `--provider browser` handoff still work and the run
continues — the report says so in its own `note` field.
Run this from the site's repo root. It reports the project, the queue, published
pages, the authority score, and whether `.seo/conventions.md` exists.
- **No project yet** → `references/workflow-setup.md`.
- **No `.seo/conventions.md`** → run setup before anything else. Do not guess site
facts. In a headless run, report "setup incomplete" and exit **zero** — an
unfinished setup is the owner's pending step, never a red run.
- **Not sure what to do** → `python3 $SEO/seostate.py next-actions`.
**Always confirm the reported `domain` is the site you mean to operate on** before
writing anything.
---
## The two files you must read
1. **`references/quality-bar.md`** — the locked standard. KD zones, the volume
band, the authority gate, the remit test, the ICP test, queue policies, the
security rule, the hard rules. **Read it before every research, build, or trend
workflow.** It defines WHAT to do and the bar.
2. **`.seo/conventions.md`** in the site's repo — the site facts. Stack, build
command, content directories, metadata contract, design tokens, exemplar
components, voice rules, and the site's FACETS. It defines how the bar maps
onto THIS repo. **Read it completely before acting.**
---
## Workflows
Load the reference file for the workflow you are running and follow it exactly.
The pipelines are deliberately specific; improvising them is how a site ends up
with twenty pages that read like one template.
| Workflow | Cadence | Reference |
|---|---|---|
| **setup** | once | `references/workflow-setup.md` |
| **research** | weekly | `references/workflow-research.md` |
| **build-guide** | daily | `references/workflow-build-guide.md` |
| **build-tool** | weekly / on approval | `references/workflow-build-tool.md` |
| **trend-scan** / **trend-expand** | on demand | `references/workflow-trends.md` |
| **geo-scan** (AI visibility) | weekly | `references/workflow-geo-scan.md` |
| **backlinks** + **report** | on demand | `references/workflow-backlinks-report.md` |
| **decay** (what is quietly losing) | monthly | `references/workflow-decay.md` |
| **drift** (what changed on page 1) | fortnightly | `references/workflow-drift.md` |
| **crawl-log** (what bots actually did) | monthly | `references/workflow-crawl-log.md` |
| **programmatic** (generated silos) | before shipping, then quarterly | `references/workflow-programmatic.md` |
| **health** (`sitecheck.py` + link graph → queue) | quarterly | `references/workflow-health.md` |
| **contract** (did the deploy break it) | **after every deploy** | `references/workflow-contract.md` |
| **international** (the hreflang mesh) | quarterly + on locale change | `references/workflow-international.md` |
| **audit** (one page, basic or full HTML report) | on demand | `references/workflow-audit.md` |
The five above `contract` all **measure what already exists** rather than
proposing something new — on a site with any history that is where the return
is, and `crawl-log` is the only **first-party** measurement here, reading the
server's own record instead of asking a third party what it thinks.
The last two are **guards on your own markup**:
- **contract** is the fastest-paying workflow here: a shipped `noindex` or a
rewritten canonical is invisible for weeks. Run it after every deploy, before
anything slower — if the contract broke, nothing downstream is measuring what
you think it is.
- **international** applies only to a multi-locale site, where hreflang fails
**silently and bidirectionally** and Search Console no longer reports it.
⚠ **`contract` and `drift` are different things.** `drift.py` watches **their**
page 1; `contract.py` watches **your** markup.
Supporting references:
- `references/scripts.md` — the full script table, the command cookbook, and the
per-script traps. **Read it before running any script beyond the preflight.**
- `references/data-sources.md` — every provider, what it costs, and **what was
measured to actually work** from a container. Read it when a data call fails or
before adding a provider.
- `references/prior-art.md` — the open-source landscape, the ranked gaps, and
why the script layer is stdlib cleanroom. Read it before adding a dependency
or a script.
- `references/backlink-playbook.md` — the curated directory list, ordered by
value, with an explicit do-not-buy section.
- `references/agent-readiness.md` — the three AI-crawler classes, `llms.txt`,
Lighthouse `agentic-browsing`, WebMCP. **Read it before writing anything about
GEO, `llms.txt` or AI crawlers into a report.**
- `references/schema-gates.md` — retired rich-result types, with dates. Read it
before adding or reporting on structured data; the validator never flags them.
- `references/deslop.md` — the AI-writing-tell catalog behind `slop.py`, and how
to read a report that deliberately has no score.
- `references/automation.md` — GitHub Actions and cron templates. Read it when
scheduling runs or when a scheduled run goes quiet.
- `references/audit-reference.md` — the audit's field definitions, scope and
edge cases. Read it with `workflow-audit.md` before writing an audit report.
Sibling skills, separate on purpose: **`search-console`** (hand-written curl
recipes for ad-hoc Search Console questions - `gsc.py` is the instrument this
skill uses) and **`adsense`** (ad placement, consent, ads.txt and AdSense
revenue).
---
## The scripts
38 scripts, all stdlib Python 3, no installs. Every one prints JSON; `--help`
lists the subcommands. Re-declare `SEO=` first (see above).
The ones you touch most:
| Script | Job |
|---|---|
| `seodoctor.py` | self-healing preflight — run it first, every run |
| `controls.py` | the control primitive, and `controls.py audit` — which instruments can currently prove they discriminate. Run it before trusting any zero |
| `seostate.py` | all state: queue, keywords, ranks, pages, trends, profile, pacing, overview, next-actions, run log |
| `serp.py` | live SERPs through the provider ladder, plus the weakness/authority scoring the gate needs; `--provider bing` (opt-in) reads Bing's own page 1 behind named refusals |
| `keywords.py` | expansion across six independent suggestion corpora, with a cross-engine agreement signal |
| `sameness.py` | the corpus sameness gate + a pairwise drift audit |
| `headterms.py` | which URL OWNS each head phrase (`.seo/headterms.json`), and every page whose title or H1 took one anyway — the cannibalisation sameness cannot see, because the bodies differ |
| `claims.py` | the published copy against the product's verified-facts ledger (`.seo/claims.json`): known-false claims as findings, competitor-plus-preference sentences for review |
| `geo.py` | do answer engines cite us? Google's AI Overview and AI Mode work today on the SerpApi key; `no_key` engines report cannot-ask, never not-cited. **`--runs 3`** — answers are non-deterministic, and one run is a coin toss labelled as one |
| `remeasure.py` | did the change work? Hypotheses with pre-registered directions, re-checked by re-running the same command |
| `brief.py` | a build brief assembled from measurements — and a hard refusal when page 1 could not be read |
| `vitals.py` | whole-site Core Web Vitals sampled per TEMPLATE, keyless — and a network baseline, because the first version blamed the site for the container's DNS |
| `sitegraph.py` | the internal link graph, offline or live — orphans, click depth, broken links, and the ISLAND silos that look well-linked and are reachable from nowhere; `suggest` names which pages should link to the weak ones |
| `sitecheck.py` | the site-level checks health calls non-negotiable — hosts, soft 404, sitemap, redirects, canonicals, headers, duplicate variants — with a `diff` between runs |
| `algoupdates.py` | Google's update calendar synced from Google's own Search Status Dashboard, with real rollout spans; `seodoctor` re-syncs it when stale |
| `stats.py` | Wilson / Newcombe / exact McNemar / Holm — the only way a rate here is allowed to "change" |
**Read `references/scripts.md` before running anything else** — it carries the
full table (research, measurement, guards, tests), the command cookbook, and the
per-script traps that are not guessable: `serpd.py --start` must never take a
trailing `&`, `crawllog.py --glob` must be quoted, `hreflang.py control` runs
before any audit, and `bing.py sites` is the auth control to run first when
anything looks odd. Every Bing write is a dry run until `--yes` and is
confirmed by reading it back, because Bing answers a no-op with the same null
as a success.
---
## The state layer is a policy engine, not a database
`seostate.py` enforces the queue policies so a workflow cannot quietly break them:
- On a **semi** project, an `approved` you request for an agent-sourced idea is
**recorded as `pending`** and the response says so. **That counts as success —
do not retry.**
- **Tool** approvals are coerced to pending unless `auto_approve_tools` is on.
- **Trend takes** are always coerced to pending — the owner is the taste gate.
- Proposing a keyword already in the queue returns `duplicate: true` instead of
creating a second row.
- A profile that breaks a directory's length contract is **refused**, not saved.
Read the `coerced` and `recorded_status` fields in every response. They are the
answer, not an error.
---
## Non-negotiables
These are the ones that get broken first under time pressure, so they are here as
well as in the quality bar:
- **Never fabricate data.** No invented volumes, difficulties, positions, or
stats, ever. A failed tool call is **reported**, not papered over. Missing data
is a data gate that does not apply — never a gate the candidate failed.
This includes **absence returned by a working API**: Open PageRank's
`found: false` means the link graph has never seen that domain, which is not
a DR of 0, and Wikimedia pageviews are topic interest, not search volume.
Neither may be substituted for the number it resembles. Likewise
**Bing impressions are not Google volume**, **engine agreement is ordinal,
not a volume**, **Tranco rank is popularity, not authority**, and **GDELT is
press coverage, not demand**.
- **A source you cannot cite, you have not verified.** `factcheck.py` returns
papers with DOIs and citation counts; that makes them **candidates to read**.
Citing one without opening it is fabrication with a reference attached.
`factcheck.py claims --draft X --fetch` is the instrument: every
number in the draft, whether it is cited within 200 characters, and whether
the cited page actually carries it. `not_in_source` is the finding;
`unverified` (the page could not be read) is unknown, never false.
- **A negative result is only as good as its control.** Before reporting that
something is absent — not indexed, not crawled, not cited, not in the corpus,
a bot that is spoofed — run the same probe against something you KNOW is
present. **"Cannot ask" and "the answer is no" must never share a code
path:** a probe whose control fails refuses a verdict, never reports quiet.
Every instrument carries a runnable control (`control` / `--control`), and
`controls.py audit` names any that cannot currently prove itself. An empty
control set is not a pass, a uniform verdict across a population is the
tell, and a control that copies its expected value from the code is a
mirror. **Read `references/controls.md` before writing a new probe or
reporting any absence** — it has the incidents that made this a rule.
- **A rate that moved is not a rate that changed.** CTR between two windows, a
citation rate between two sweeps, a remeasured share: each is a change ONLY
when its 95% interval excludes zero (`stats.compare_rates`, Newcombe's hybrid
score interval). With this skill's sample sizes most movements are noise -
3/10 → 5/10 is `no_detectable_change`. Report the counts with the rate, always.
- **Every AI-citation claim names its n.** An answer engine is non-deterministic:
the same prompt cites different sources run to run, so "not cited" from one
run is a coin toss reported as a state. `geo.py --runs 3` (or more) reports
the rate with its sample size; a `single_observation: true` row may be
reported only as one observation, never as a rate or a trend point. And the
ladder has rungs — *retrieved* (`crawllog`), *cited*, *mentioned*,
*recommended* — measured by different instruments; a citation is not a
recommendation, and the framing of a mention is a human reading of the
verbatim sentence, never an auto-label.
- **Every position claim names its ENGINE and its EXIT COUNTRY.** "We rank #2" is
not a finding; "#2 on DuckDuckGo from a US exit" is. A read through a residential
proxy on an unpinned session came from *one* exit country nobody chose, and
reporting it bare silently promotes a local observation into a global fact. Pin it
(`serp.py --proxy-country`), name it, name the engine. Where a country cannot be
pinned, that is **unmeasured**, never confirmed.
- **A rank drop needs two reads.** One SERP read is one sample of a personalised,
A/B-tested page. `rankcheck.py` re-reads every drop candidate and reports only
`confirmed` drops; a null position is `out_of_range` at the depth it was read,
never a position, and a previously-unranked keyword can never "drop".
- **A verified-country list is a measurement with a date on it.** Re-measure with `serp.py --verify-countries` before reading an absence as
evidence. The dangerous case is not a country that fails but one that silently
returns **a different country's SERP** (measured: `fr` → a GB exit).
- **A refused SERP read is a failed read, never an empty page 1.** `serp.py`
rejects two shapes that both look like success: an HTTP 200 with nothing
parseable, and — measured on real Bing responses — a full page of well-formed
results *for a different query*. Both come back `ok: false`. No usable read
means no authority count, and no authority count means the candidate does not
pass.
- **A guard that refuses is working, not failing.** `hreflang.py` refuses a
verdict when its parser control fails; `contract.py check` refuses one when
most of the URL set is down, because a site-wide outage is not an SEO
regression and recording it as one opens a critical finding on every page.
Report the refusal and its reason. **Never** report a pass from a run that
refused, and never widen `--max-fail-share` to make a refusal go away.
- **Edge rules are not robots.txt.** A CDN can prepend rules nobody committed
(Cloudflare's managed block), serve a crawler a different robots.txt, or 403 a
crawler the file allows. `agentcheck.py policy` names the first two and `reach`
the third - and `reach` answers `cannot_ask` rather than "blocked" when the edge
also refuses a forged Googlebot, because then it is verifying by IP and a forged
UA proves nothing.
- **`llms.txt` is not a ranking or citation lever.** Google's own docs say
Search ignores it, and 0.1% of AI-bot requests touch it. Report it as
optionality; never propose building one as a GEO action, and never let
Lighthouse's `agentic-browsing` llms.txt check be reported as a Google
signal. `references/agent-readiness.md` has the sources.
- **A retired rich-result type is a reason not to ADD it, and only sometimes a
reason to remove it.** The markup stays valid and other consumers may still
read it. `pagecheck.py schema` reports these at info severity for that
reason — do not escalate them.
**Locked in the workflow files, restated here in one line each because they are
the first to go under time pressure** — the full rule, its evidence and its
exact command live where the pointer says:
- **Never push to main.** Always a PR, always labeled `seo`. (`quality-bar.md` §9)
- **Never end a run short.** No SERP-check budget; a run ends when the queue is
full or every rung-1 seam is exhausted, never on a counter, a throttle or a
refused read — a refused read is a RETRY, then `seodoctor.py --hard`.
(`quality-bar.md` §2, "ordered, not rationed")
- **The authority count on page 1 overrules KD, both ways — and 4+ is DEFERRED
with a class, never a bare "rejected".** `authority` / `catalogue` /
`brand_navigational` / `dev_intent` / `off_remit` each imply a different
action; a bare "rejected — X owns it" hid three listing prospects on
2026-09-14. (`quality-bar.md` §2, the deferral table)
- **The remit test runs first and costs nothing.** Audience overlap is not remit.
(`quality-bar.md` §4)
- **Verify a product claim in the SOURCE before building on it** — positioning
View on GitHub