The contract
Who should use this | Anyone who has installed two or more skills in this series and wants one command that runs them all |
The finished outcome | A weighted 7-category audit board, a coverage report, a prioritized action plan, and a |
What you need before you start | Python 3, the site URL, and whichever series skills you intended to install |
Time to run the first audit | A few minutes of real scanning, zero configuration |
Definition of done | Every row of the board names its source, every PENDING row names the data that would fill it, and the health score is only printed when all seven categories have a measured row |
The one-paragraph version
This is the last skill in the series, and it is the one that composes all the others. You have installed eleven scanners that each look at one axis of a site: technical, content, schema, images, GEO readiness, local signals, hreflang, sitemaps, page-level structure, planning reconnaissance, environment. Running eleven commands by hand on every audit is the wrong use of your time, and calling the result "audited" after running two of them is the wrong use of a word. The composer does five things: it probes the site itself (status, title, meta, H1/H2, JSON-LD, security headers, robots, sitemap), it inventories which series skills are installed and names the posts that install the missing ones, it runs every single-URL scanner as a subprocess and saves the raw output, it assembles the weighted 7-category board where every row quotes the scanner that produced it, and it writes the three artifacts that make an audit useful later: a report, an action plan, and the JSON envelope that report generators consume. The honesty rule is the part that matters most: if any of the seven categories is not measured, the health score is withheld, and the output tells you exactly which input (a GSC CSV, a SERP capture, a CWV key) would complete the row.
Why audits die in two common ways
Full-site audits fail in two predictable shapes, and they are opposite failures.
The first is the theater shape: the audit spawns a dozen agents in parallel and then produces a single score that nobody can argue with, because nobody can find the row beneath the score. When the following quarter arrives and the score has not improved, the audit has nothing to teach the room, because the evidence was compressed into a number and thrown away.
The second is the itinerary shape: the audit produces a fifty-page document of findings, prioritized by a model's intuition. That document reads as authority, but each finding came from the same unmeasured place, and the ones that mattered were wrong or unverifiable. The report ages and nobody re-reads it.
The composer is built against both. Each row keeps its source visible and its verdict verbatim, so the board is arguable row by row. And the board is rerunnable: the same command on the same URL a month from now shows which rows moved. An audit that cannot be re-run is a snapshot; the thing that changes a site is the loop.
What the skill actually does
Five phases, one rule.
1. Probe. The composer fetches the homepage and reads the same facts every other scanner reads: status, final URL, title and meta lengths, H1/H2 counts, JSON-LD block count, image alt coverage, security headers, plus robots.txt sitemap lines and the sitemap's declared URL count. It also detects business-type signals from the homepage text (SaaS, local service, e-commerce, publisher, agency) using the signal table from the original orchestrator, because the board reads differently for a local restaurant than for a software catalog.
2. Inventory. It scans the skills directory for codex-seo-*/SKILL.md. Installed skills are listed; missing ones are listed with the post that installs them. The board's PENDING rows are not gaps in your site, they are gaps in your setup, and telling them apart is the first useful thing the composer does.
3. Run. Every series scanner that accepts a single URL is run as a subprocess: technical, page, content, schema, images, sitemap, geo, local, hreflang, plan, ready. Each gets a 90-second cap, its full stdout and stderr is saved verbatim to findings/<skill>.txt, and its own verdict lines are quoted into the board. The scanners that need inputs only you can provide (Google Search Console CSV, a link inventory, a SERP capture, a product-page argument) are listed as PENDING with the input named. The composer never invents those inputs, and never scores a row it did not run.
4. Board. The seven scorecard rows use the weights from the original claude-seo scorecard: Technical 22, Content 23, On-Page 20, Schema 10, Performance 10, AI Search Readiness 10, Images 5. Every row shows status, source, and the scanner's own verdict text.
5. Artifacts. It writes {hostname}-audit/FULL-AUDIT-REPORT.md (the board table plus coverage), ACTION-PLAN.md (measured rows in Phase 1, data-required rows in Phase 2), audit-data.json in the original envelope shape, and the findings/ directory with raw outputs. The envelope is the shape the original report generator consumes, so a dashboard or PDF pipeline that worked with the original project can read this one.

The SKILL.md below spells out the phases, the weights, the PENDING rule, and the error cases. The script is next, then the workflow.
---
name: codex-seo-audit
description: Use when the user asks for a full website audit, a whole-site SEO check, a health score for their site, or a consolidated report that combines every installed codex-seo-* skill. Composes the series skills into a weighted board, and withholds the health score when any category lacks a measured source.
---
# Full-Site Audit Composer
The finale skill: one URL in, one board out, every row traceable to the
scanner that produced it. An audit is not one score; it is a board of
category rows, each backed by its own measured source, plus a report that
names the rows it could not measure and why.
## Commands
```
python3 fullsite_audit.py <url> [--skills-dir DIR] [--json]
```
- `<url>`: the site to audit. The composer probes the homepage, robots.txt
and sitemap itself, then runs every single-URL scanner it finds among
installed `codex-seo-*` skills.
- `--skills-dir DIR`: where the skills live. Defaults to `~/.codex/skills`;
point it at a test directory or a second machine's layout when auditing
a setup you do not control.
- `--json`: prints the audit envelope (`audit-data.json` shape)
machine-readable.
Run it again any time. An audit is a rerunnable board, not an event.
## The five phases
1. **Probe**: homepage (status, canonical, title/meta lengths, H1/H2
counts, JSON-LD block count, security headers, image alt coverage),
robots.txt (sitemap lines), sitemap discovery and URL counts, and
business-type signals from the homepage text.
2. **Inventory**: scans the skills directory for `codex-seo-*/SKILL.md`,
lists installed vs missing, and points the missing ones at the series
post that installs them.
3. **Run**: executes the single-URL scanners (technical, page, content,
schema, images, sitemap, geo, local, hreflang, plan, ready) as
subprocesses with a 90-second cap, saves each full stdout to
`findings/<skill>.txt`, and quotes each scanner's own verdict lines.
4. **Board**: the 7 weighted scorecard rows (below). Every row shows its
status (OK / PENDING / NOT INSTALLED / TIMEOUT / ERROR), its source
file, and its own verdict text.
5. **Artifacts**: writes `FULL-AUDIT-REPORT.md`, `ACTION-PLAN.md`,
`audit-data.json` (the claude-seo envelope shape) and `findings/*.txt`
under `{hostname}-audit/` in the current directory.
## Weighted board
| Category | Weight | Scanner source |
|----------|--------|----------------|
| Technical SEO | 22% | technical_scan.py + own probes |
| Content Quality | 23% | content_scan.py |
| On-Page SEO | 20% | page_scan.py + own probes |
| Schema / Structured Data | 10% | schema_scan.py |
| Performance (CWV) | 10% | PENDING by default - PSI/CrUX key (technical_scan --psi) or field CSV |
| AI Search Readiness | 10% | geo_scan.py |
| Images | 5% | image_scan.py |
## The honesty rule (the one thing that never bends)
- Each board row quotes the verdict of the scanner that produced it. The
composer does not re-score, re-interpret, or synthesize numbers.
- Rows that need data the public web does not carry (GSC CSV, backlink
export, SERP capture, CWV field data) are labeled PENDING with the
missing input named, and they count as not-measured.
- If any of the 7 categories is not measured, the health score is
withheld - the output prints "HEALTH SCORE: withheld" and says which
rows and which inputs are missing. A score over partial evidence is the
one thing this family of skills never prints.
## Output contract
- `FULL-AUDIT-REPORT.md`: board table + coverage + run date.
- `ACTION-PLAN.md`: Phase 1 (measured rows, week 1) and Phase 2
(data-required rows, when data arrives). Items are tasks, not promises.
- `audit-data.json`: the envelope (summary, categories, action_plan,
artifacts) that report generators and dashboards consume.
- `findings/`: per-scanner full stdout and stderr, saved verbatim.
## Error handling
| Scenario | Action |
|----------|--------|
| URL unreachable / 403 / TLS | Probe prints the error and the Rule: an audit starts at the server. Do not guess content. |
| A scanner fails or times out | Row status shows TIMEOUT/ERROR with its exit reason; the run continues. |
| Skill not installed | Row says NOT INSTALLED and names the post that installs it. The board stays honest. |
| `--skills-dir` points at an empty dir | Inventory reports NONE; scanner runs all NOT INSTALLED; artifacts still write. |
| Only some categories measured | Health score withheld; the coverage line says which rows. Never fill a missing row from a different source than the mapping above. |
| site-audit artifacts already exist | 90-second overwrite? No - `{hostname}-audit/` files are overwritten silently by re-run, since the audit is rerunnable by design. |
| Skill scripts fail on Windows | The subprocess runs with `sys.executable`; use the same paste-and-run path as the post for each skill. |#!/usr/bin/env python3
"""Full-site audit composer for the Codex SEO Skills series.
One URL in, one board out: probes the site, inventories installed
codex-seo-* skills, runs the single-URL scanners among them, quotes each
scanner's own verdict, writes the claude-seo audit envelope
(FULL-AUDIT-REPORT.md / ACTION-PLAN.md / audit-data.json / findings/*.md),
and only prints a health score when every category has a measured source.
Standard library only. Usage:
python3 fullsite_audit.py <url> [--skills-dir DIR] [--json]
"""
import gzip
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from urllib.parse import urlparse
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
TIMEOUT = 25
CAP = 6 * 1024 * 1024
TODAY = __import__("datetime").date.today().isoformat()
CATEGORY_WEIGHTS = [ # weights from the original claude-seo audit scorecard
("Technical SEO", 22, "technical"),
("Content Quality", 23, "content"),
("On-Page SEO", 20, "page"),
("Schema / Structured Data", 10, "schema"),
("Performance (CWV)", 10, "performance"),
("AI Search Readiness", 10, "geo"),
("Images", 5, "images"),
]
# Registry: series skill -> (script basename, args template, runs-on-bare-url?)
# args: "%url%" replaced with the audit URL; None means the skill is PENDING
# (needs an extra input file/join that only the user can provide).
SKILL_RUNS = [
("ready", "check_env.py", [], None),
("technical", "technical_scan.py", ["%url%"], None),
("page", "page_scan.py", ["%url%"], None),
("content", "content_scan.py", ["%url%"], None),
("schema", "schema_scan.py", ["%url%"], None),
("images", "image_scan.py", ["%url%"], None),
("sitemap", "sitemap_check.py", ["%url%"], None),
("geo", "geo_scan.py", ["%url%"], None),
("local", "local_scan.py", ["%url%"], None),
("hreflang", "hreflang_check.py", ["%url%"], None),
("plan", "plan_recon.py", ["%url%"], None),
("google", "gsc_csv.py", None, "needs a GSC performance CSV export (see the google-data post)"),
("backlinks", "backlinks_audit.py", None, "needs a links CSV or --gsc-export (see the backlinks post)"),
("cluster", "serp_cluster.py", None, "needs a SERP TSV capture (see the cluster post)"),
("sxo", "sx_scan.py", None, "needs a SERP capture (see the SXO post)"),
("hreflang-parity", "hreflang_parity.py", None, "needs locales.tsv (see the hreflang post)"),
("programmatic", "pp_scan.py", None, "needs a sitemap URL and pattern decision (see the programmatic post)"),
("competitor-pages", "competitor_facts.py", None, "needs competitor URLs (see the competitor-pages post)"),
]
def fetch(url, _retried=False):
req = urllib.request.Request(url, headers={
"User-Agent": UA, "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
"Accept-Encoding": "gzip", "Accept-Language": "en-US,en;q=0.9",
})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
body = r.read(CAP + 1)
if len(body) > CAP:
return None, None, "body too large (over 6MB)"
if body[:2] == b"\x1f\x8b" or r.headers.get("Content-Encoding", "").lower() == "gzip":
body = gzip.decompress(body)
hdr = dict((k.lower(), v) for k, v in r.headers.items())
return body.decode("utf-8", "replace"), hdr, None
except urllib.error.HTTPError as e:
return None, None, f"HTTP {e.code}"
except urllib.error.URLError as e:
err = re.sub(r"\s+", " ", str(e.reason).strip("<> "))[:60]
if not _retried and re.search(r"(?i)EOF|ssl|connect|reset|timeout", err):
return fetch(url, _retried=True)
return None, None, err
except Exception as e:
err = f"{type(e).__name__}: {e}"[:60]
if not _retried and re.search(r"(?i)EOF|ssl|connect|reset|timeout", err):
return fetch(url, _retried=True)
return None, None, err
def metal(html, needle):
for pat in (r'<meta[^>]+(?:name|property)=["\']' + needle + r'["\'][^>]*content=["\']([^"\']+)',
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]*(?:name|property)=["\']' + needle +
r'["\']'):
m = re.search(pat, html, re.I)
if m:
return m.group(1)
return None
def read_xml_locs(body):
return re.findall(r"<loc>(.*?)</loc>", body, re.S)
def probe_site(url):
"""Own measurements: homepage health + robots + sitemap + business signals."""
out = {"url": url, "status": "unreachable"}
html, hdr, err = fetch(url)
if err or html is None:
out["errors"] = [err or "no page"]
return out
out["status"] = 200
txt = re.sub(r"<script.*?</script>|<style.*?</style>", " ", html, flags=re.I | re.S)
txt = re.sub(r"<[^>]+>", " ", txt)
txt = re.sub(r"\s+", " ", txt).lower()
ti = re.search(r"<title[^>]*>(.*?)</title>", html, re.I | re.S)
out["title_chars"] = len(ti.group(1).strip()) if ti else 0
md = metal(html, "(?:description|og:description)")
out["meta_desc"] = len(md) if md else 0
out["h1"] = len(re.findall(r"<h1[^>]*>", html, re.I))
out["h2"] = len(re.findall(r"<h2[^>]*>", html, re.I))
imgs = re.findall(r"<img[^>]*>", html, re.I)
alt = [i for i in imgs if re.search(r'alt=["\'][^"\']+', i, re.I)]
out["images"] = {"total": len(imgs), "with_alt": len(alt)}
out["jsonld_blocks"] = len(re.findall(r'<script[^>]*type=["\']application/ld\+json["\']', html, re.I))
out["security_headers"] = [k for k in ("strict-transport-security", "x-content-type-options",
"content-security-policy", "referrer-policy")
if hdr and k in hdr]
out["final_url"] = url
# business-type signals (per the original orchestrator's detection table)
signals = {
"SaaS": bool(re.search(r"pricing|/features|/integrations|/docs|free trial|sign up", txt)),
"Local service": bool(re.search(r"phone|address|serving [a-z]{3,}|map embed|google maps", txt)),
"E-commerce": bool(re.search(r"/products|/collections|/cart|add to cart", txt)),
"Publisher": bool(re.search(r"/blog|/articles|/topics", txt)),
"Agency": bool(re.search(r"case-stud|portfolio|/industries|our work|client logo", txt)),
}
out["business_signals"] = {k: v for k, v in signals.items() if v} or {"none": True}
# robots + sitemap
base = url.split("//")[0] + "//" + url.split("//")[1].split("/", 1)[0] if "//" in url else url
out["base"] = base
rb, _, rerr = fetch(base + "/robots.txt")
sm_url = None
out["robot_sitemap_lines"] = 0
if rb is not None and "<html" not in rb.lower()[:400]:
lines = [l.strip().split(None, 1)[-1] for l in rb.splitlines()
if re.match(r"(?i)^sitemap\b", l) and ":" in l]
out["robot_sitemap_lines"] = len(lines)
sm_url = lines[0] if lines else None
if not sm_url:
sm_url = base + "/sitemap.xml"
sb, _, serr = fetch(sm_url)
out["sitemap"] = {"file": sm_url, "found": sb is not None}
if sb is not None:
locs = read_xml_locs(sb)
out["sitemap"]["urls_declared"] = len(locs)
out["sitemap"]["kind"] = "index" if "<sitemap>" in sb else "single"
if out["sitemap"]["kind"] == "index":
total = 0
for c in locs[:8]:
cb, _, _ = fetch(c)
if cb is not None:
total += len(read_xml_locs(cb))
out["sitemap"]["urls_children_total"] = total
out["sitemap"]["robots_hint"] = "yes" if out["robot_sitemap_lines"] else "no (default path)" \
if out["sitemap"]["found"] else "no"
else:
out["sitemap"]["error"] = serr or "not found"
return out
def inventory(skills_dir):
expected = {name for name, _, args, note in SKILL_RUNS}
found = {}
if os.path.isdir(skills_dir):
for d in sorted(os.listdir(skills_dir)):
p = os.path.join(skills_dir, d)
if os.path.isfile(os.path.join(p, "SKILL.md")):
key = d[10:] if d.startswith("codex-seo-") else d
found[key] = p
rows = []
for name in sorted(expected):
rows.append({
"skill": name,
"installed": name in found,
"scripts_dir": os.path.join(found[name], "scripts") if name in found else None,
"expected": True,
})
return rows, found
def find_script(found, skill, basename):
d = found.get(skill)
if not d:
return None
path = os.path.join(d, "scripts", basename)
return path if os.path.isfile(path) else None
def run_scanner(outdir, skill, script_path, args):
started = time.time()
redirs = {"%url%": args["url"]}
cmd = [sys.executable, script_path] + [a.replace("%url%", redirs["%url%"]) for a in args["argv"]]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
rc, out, errout = r.returncode, r.stdout or "", r.stderr or ""
except subprocess.TimeoutExpired:
return {"skill": skill, "status": "TIMEOUT (>90s)", "source": "subprocess",
"files": [], "elapsed": round(time.time() - started, 1)}
except Exception as e:
return {"skill": skill, "status": "ERROR", "source": "subprocess",
"error": f"{type(e).__name__}: {e}", "elapsed": round(time.time() - started, 1)}
# save full output
os.makedirs(os.path.join(outdir, "findings"), exist_ok=True)
fn = os.path.join(outdir, "findings", skill + ".txt")
with open(fn, "w", encoding="utf-8") as f:
f.write("$ " + " ".join(cmd) + "\n" + (out or "") + (("\n--- stderr ---\n" + errout) if errout else ""))
verdict_lines = [l for l in (out.splitlines() or [])[-25:]
if re.search(r"(?i)score|verdict|pass|fail|warn|flag|health|not publicly|no local|not a product", l)]
verdict = verdict_lines[-6:] if verdict_lines else (
out.splitlines()[-3:] if out else (errout.splitlines()[-3:] or ["(no output)"]))
return {"skill": skill, "status": "OK" if rc == 0 else f"EXIT {rc}", "source": "subprocess",
"verdict_excerpt": "\n".join(verdict)[:900], "file": "findings/" + skill + ".txt",
"elapsed": round(time.time() - started, 1)}
def row_from_scanner(run, cat_name, weight):
return {"category": cat_name, "weight": weight, "status": run["status"],
"source": run.get("file") or run.get("error") or run["status"],
"verdict": run.get("verdict_excerpt", "")}
def main():
args = [a for a in sys.argv[1:]]
json_mode = "--json" in args
rest = [a for a in args if not a.startswith("--")]
skills_dir = "~/.codex/skills"
if "--skills-dir" in args:
i = args.index("--skills-dir")
skills_dir = args[i + 1]
skills_dir = os.path.expanduser(skills_dir)
if not rest:
print("usage: fullsite_audit.py <url> [--skills-dir DIR] [--json]", file=sys.stderr)
return 2
url = rest[0]
board = []
print("FULL-SITE AUDIT: " + url)
print("=" * 50)
print("Phase 1 - site probes")
p = probe_site(url)
if p["status"] != 200:
print("SITE UNREACHABLE: " + "; ".join(p.get("errors", [])))
print("Rule: a full-site audit of an unreachable site starts at the server, not at the categories.")
board.append({"category": "Site", "weight": 0, "status": "UNREACHABLE",
"source": p.get("errors"), "verdict": "fix the server first"})
else:
print("status 200 | final URL: " + str(p.get("final_url")))
print("title chars: %s meta desc: %s h1: %s h2: %s" %
(p["title_chars"], p["meta_desc"], p["h1"], p["h2"]))
im = "images: %s total, %s with alt" % (p["images"]["total"], p["images"]["with_alt"])
print(im)
print("jsonld blocks: %s security headers: %s" % (p["jsonld_blocks"],
", ".join(p["security_headers"]) or "NONE"))
print("business signals: " + ", ".join(p["business_signals"].keys()))
sm = p["sitemap"]
line = "sitemap %s (%s): %s" % (sm["file"], sm.get("kind"), sm.get("urls_declared"))
if sm.get("urls_children_total") is not None:
line += " (children: " + str(sm["urls_children_total"]) + ")"
if sm.get("error"):
line += " ERROR " + str(sm["error"])
else:
line += " robots hint: " + str(sm.get("robots_hint"))
print(line)
board.append({"category": "Site probes", "weight": 0, "status": "MEASURED",
"source": "own probe", "verdict": "ok"})
print("\nPhase 2 - skill inventory (" + skills_dir + ")")
inv, found = inventory(skills_dir)
installed = [r["skill"] for r in inv if r["installed"]]
print("installed skills: " + (", ".join(installed) or "NONE"))
missing = [r["skill"] for r in inv if not r["installed"]]
if missing:
print("missing (see the matching series post to install): " + ", ".join(missing))
parsed = urlparse(url)
domain = re.sub(r"^www\.", "", (parsed.netloc or parsed.path.split("/")[0]).lower())
outdir = os.path.join(os.getcwd(), domain + "-audit")
os.makedirs(outdir, exist_ok=True)
print("\nPhase 3 - scanner runs (each verdict is the scanner's own)")
runs = []
for name, base, argv, note in SKILL_RUNS:
if argv is None:
runs.append({"skill": name, "status": "PENDING", "note": note})
continue
script_path = find_script(found, name, base)
if not script_path:
runs.append({"skill": name, "status": "NOT INSTALLED"})
continue
rr = run_scanner(outdir, name, script_path, {"url": url, "argv": argv})
runs.append(rr)
st = rr["status"]
line_tail = " ".join(rr.get("verdict_excerpt", "").replace("\n", " | ")[:140].split())
if rr.get("error"):
line_tail = rr["error"]
print("%-14s %-9s %s%s" % (name, st, line_tail,
(" " + str(rr.get("elapsed")) + "s") if "elapsed" in rr else ""))
print("\nPhase 4 - weighted board (the audited scorecard)")
cat_rows = []
for cat_name, weight, skill_key in CATEGORY_WEIGHTS:
run = next((r for r in runs if r["skill"] == skill_key), None)
if skill_key == "performance" and run is None:
run = {"skill": "performance", "status": "PENDING",
"note": "no scanner by default - PSI key (technical_scan --psi) or field CSV"}
if run is None or run["status"] in ("PENDING", "NOT INSTALLED"):
status = run["status"] if run else "MISSING"
note = run.get("note", "") if run else "no scanner"
cat_rows.append({"category": cat_name, "weight": weight, "status": status,
"source": note or "no scanner", "verdict": ""})
print("%-24s %-13s (weight %s%%) %s" % (cat_name, status, weight, note))
else:
cat_rows.append(row_from_scanner(run, cat_name, weight))
verdict = " ".join(cat_rows[-1]["verdict"].replace("\n", " | ").split())[:150]
print("%-24s %-13s (weight %s%%) %s" % (cat_name, cat_rows[-1]["status"], weight, verdict))
measured = [r for r in cat_rows if r["status"] == "OK"]
pending = [r for r in cat_rows if r["status"] != "OK"]
print("\ncoverage: %d of 7 categories measured" % len(measured))
if pending:
print("PENDING rows (fix with data): " + "; ".join(r["category"] + " - " + str(r.get("source", ""))
for r in pending))
print("HEALTH SCORE: withheld - not every category has a measured source. "
"A score over partial evidence is the one thing this family of skills never prints.")
else:
# only score when all measured: quote the scanner verdicts, no invented maths
print("HEALTH SCORE: consistent (all 7 rows have a measured source; "
"open findings/*.txt and the board above for the per-category verdicts).")
print("\nPhase 5 - artifacts written")
for art in (outdir + "/FULL-AUDIT-REPORT.md", outdir + "/ACTION-PLAN.md",
outdir + "/audit-data.json", outdir + "/findings/"):
print(art)
print("audit run: " + TODAY + " | source URLs are public pages of the target site")
# write artifacts
categories = []
for row in cat_rows:
categories.append({"name": row["category"], "weight": row["weight"],
"status": row["status"], "source": row["source"],
"verdict": row["verdict"]})
envelope = {
"summary": {
"health_score": None if pending else "consistent",
"url": url, "business_signals": p.get("business_signals"),
"coverage": "%d/7" % len(measured), "audited_on": TODAY,
"note": "health score withheld when a category has no measured source",
},
"categories": categories,
"action_plan": {"phases": [
{"name": "Phase 1: Critical Fixes", "timeframe": "Week 1",
"items": [c for c in categories if c["status"] == "OK"]},
{"name": "Phase 2: Data-Required Rows", "timeframe": "When data arrives",
"items": [c for c in categories if c["status"] != "OK"]}]},
"artifacts": {"findings_dir": "findings/", "screenshots_dir": "n/a (no browser runtime)"},
}
with open(os.path.join(outdir, "audit-data.json"), "w", encoding="utf-8") as f:
json.dump(envelope, f, indent=1)
lines = ["# FULL AUDIT REPORT - %s" % url, "", "Audited: " + TODAY, "",
"| Category | Status | Source |", "|---|---|---|"]
for c in categories:
lines.append("| %s | %s | %s |" % (c["name"], c["status"], c["source"]))
with open(os.path.join(outdir, "FULL-AUDIT-REPORT.md"), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
plan = ["# ACTION PLAN - %s" % url, ""]
for ph in envelope["action_plan"]["phases"]:
plan.append("## " + ph["name"] + " (" + ph["timeframe"] + ")")
for c in ph["items"]:
plan.append("- [ ] " + c["name"] + " - " + c["source"])
plan.append("")
with open(os.path.join(outdir, "ACTION-PLAN.md"), "w", encoding="utf-8") as f:
f.write("\n".join(plan) + "\n")
if json_mode:
print(json.dumps(envelope, indent=1))
return 0
if __name__ == "__main__":
sys.exit(main())Install the skill
Two commands, then a smoke test.
# 1) create the skill directory
mkdir -p ~/.codex/skills/codex-seo-audit/scripts
# 2) save the SKILL.md (paste the first code block above into the file below)
nano ~/.codex/skills/codex-seo-audit/SKILL.md
# 3) save the script (paste the second code block above)
nano ~/.codex/skills/codex-seo-audit/scripts/fullsite_audit.py
# 4) smoke test - one command, default skills directory
python3 ~/.codex/skills/codex-seo-audit/scripts/fullsite_audit.py https://your-site.comIf you want Codex to do the file-writing instead of pasting, jump to the paste-it-in instruction below; it does the same thing.
Verify the install
Run it on your own site first and confirm the three things that distinguish a composer from a folder of scripts:
- The inventory line lists the series skills you actually installed, and the missing list matches what you skipped on purpose. If it says NONE for everything, you are pointing at the wrong skills directory - re-run with
--skills-dir. - The board shows which of the seven categories have a measured row. On a fresh install with two or three skills, most rows will say NOT INSTALLED or PENDING; that is the board telling you which posts to install next, not a failure.
- The coverage line and the health score line agree: no score when a row lacks a source.
Then run it against a site you have already audited by hand, and check that the rows you understood match the rows the scanners produced. If they do not, the difference is the finding.
A real run: the audit of the site this series lives on
There is nothing synthetic about the example below. This is the actual output of the composer on 2026-09-01, run against the site that hosts this series - auspia.ai, the site you are reading right now. Every score row is the scanner's own verdict, saved verbatim to the findings folder as it ran. It takes a few minutes of real scanning, uses no API keys, and costs nothing.
FULL-SITE AUDIT: https://auspia.ai
==================================================
Phase 1 - site probes
status 200 | final URL: https://auspia.ai
title chars: 46 meta desc: 80 h1: 1 h2: 9
images: 15 total, 6 with alt
jsonld blocks: 2 security headers: strict-transport-security
business signals: SaaS
sitemap https://auspia.ai/sitemap.xml (single): 1037 robots hint: yes
Phase 2 - skill inventory (skills)
installed skills: backlinks, cluster, competitor-pages, content, geo, google, hreflang, images, local, page, plan, programmatic, ready, schema, sitemap, sxo, technical
missing (see the matching series post to install): hreflang-parity
Phase 3 - scanner runs (each verdict is the scanner's own)
ready OK - codex-seo-competitor-pages planned | - codex-seo-plan planned | - codex-seo-audit planned 0.7s
technical OK - Warn: missing X-Content-Type-Options 4.1s
page OK - Warn: no H1 on page 2.1s
content OK links: 134 internal (105.3/1000w), 2 external | media: img 15 video 0 toc True | date None | author - | trust: privacy_policy, contact 1.6s
schema OK types: Organization, WebSite | block 1 [Organization]: ok | block 2 [WebSite, Organization]: ok 1.4s
images OK 13. https://auspia.ai/logo.svg 11.3kb missing-alt; no-dimensions(CLS); no-loading-hint; no 10.3s
sitemap OK sitemap https://auspia.ai/blog/blog-index.xml status 200 | xml ok: True tag: sitemapindex urls: 3 size: 0.0MB | index file with 19.1s
geo OK top fixes: | - move your best answer block into the first 30% of the page | - add publication date (and ideally last modified) 2.9s
local OK result: NO LOCAL SIGNALS 1.4s
hreflang OK WARN no hreflang tags found on https://auspia.ai 1.6s
plan OK link inventory (codex-seo-backlinks). | NOT PUBLISHABLE from this recon: any KPI row not mapped above. Leave it as a manual row until a r 2.7s
Phase 4 - weighted board (the audited scorecard)
Technical SEO OK (weight 22%) - Warn: missing X-Content-Type-Options
Content Quality OK (weight 23%) links: 134 internal (105.3/1000w), 2 external | media: img 15 video 0 toc True | date None | author - | trust: privacy_policy, contact
On-Page SEO OK (weight 20%) - Warn: no H1 on page
Schema / Structured Data OK (weight 10%) types: Organization, WebSite | block 1 [Organization]: ok | block 2 [WebSite, Organization]: ok
Performance (CWV) PENDING (weight 10%) no scanner by default - PSI key (technical_scan --psi) or field CSV
AI Search Readiness OK (weight 10%) top fixes: | - move your best answer block into the first 30% of the page | - add publication date (and ideally last modified)
Images OK (weight 5%) 13. https://auspia.ai/logo.svg 11.3kb missing-alt; no-dimensions(CLS); no-loading-hint; no-fetchpriority | 14. https://auspia.ai/logo.svg?dpl=dpl_7yYV
coverage: 6 of 7 categories measured
PENDING rows (fix with data): Performance (CWV) - no scanner by default - PSI key (technical_scan --psi) or field CSV
HEALTH SCORE: withheld - not every category has a measured source. A score over partial evidence is the one thing this family of skills never prints.
Phase 5 - artifacts written
/Users/charles/claude-felo-workspace/codex-seo-series/auspia.ai-audit/FULL-AUDIT-REPORT.md
/Users/charles/claude-felo-workspace/codex-seo-series/auspia.ai-audit/ACTION-PLAN.md
/Users/charles/claude-felo-workspace/codex-seo-series/auspia.ai-audit/audit-data.json
/Users/charles/claude-felo-workspace/codex-seo-series/auspia.ai-audit/findings/
audit run: 2026-09-01 | source URLs are public pages of the target siteReading the run
Start at the top and read it as a surface, not a verdict.
The probe line. Status 200, title 46 characters, meta description 80, one H1 in the raw HTML, nine H2s, 15 images of which 6 carry alt text, two JSON-LD blocks, one security header present (strict-transport-security), business signals classified as SaaS, and a sitemap at /sitemap.xml declaring 1,037 URLs with a robots.txt hint confirming it. Short title, short meta - both are things public recon can judge in one line, and both are worth noting before the scores start. Everything after this line is built on top of it.
The inventory. 17 of the series skills are installed and one is missing (hreflang-parity, and the missing line names the post that installs it). This is the first division the composer draws, and the most useful one: gaps in your setup versus gaps in the site. The PENDING rows on the board below are setup gaps, not site gaps.
The scanner rows. All eleven URL-driven scanners returned OK, and OK means "the scanner ran to completion and its verdict is what follows", not "clean". A few rows repay a second look:
- technical warns about a missing X-Content-Type-Options. One extra header, one line in the report, and it is the kind of warning a real deployment checklist keeps.
- page reports "no H1 on page" while the probe, seconds earlier, counted one H1 in the raw HTML. Two measurements of the same page, disagreeing. The composer quotes both and refuses to reconcile them, because it cannot - the finding file for the page scanner is the tiebreaker, and that mismatch is exactly the kind of row worth opening before you present the board.
- content counts 134 internal links at 105.3 per 1,000 words, two external links, a table of contents, no publication date, no author byline, and privacy/contact pages present on the trust side. Hold on to "date None"; it comes back.
- images names the worst offender in plain terms: a logo SVG at 11.3 KB with missing alt text, no dimensions, no loading hint. The site publishing this series ships that. Missing dimensions on a logo is a CLS risk, and the scanner caught it on the front page of the site it was written for.
- sitemap is where the probe and a scanner appear to disagree, and the disagreement is instructive. The probe read /sitemap.xml as a single sitemap declaring 1,037 URLs. The sitemap scanner separately discovered /blog/blog-index.xml - an index file in its own right, with 3 child URLs, and its verdict line truncates mid-sentence at "index file with". Which XML Google actually reads is a Search Console question, and the row tells you what to ask.
- geo prints two concrete fixes: move the best answer block into the first 30% of the page, and add a publication date. The second one is the same "date None" from the content row. Two categories, one underlying finding - that is what composing the audit buys you.
- local returns "NO LOCAL SIGNALS" and the row still says OK, because the probe classified the business type as SaaS. For a restaurant with a Google Business Profile, this row would read very differently. The board is only as interpretable as the business signal the probe caught.
- hreflang warns that no hreflang tags were found. For a single-language site that can be a deliberate, correct choice. The scanner warns; you decide.
The board. Six of seven categories measured. The missing one is Performance (10%), and the row says why: "no scanner by default - PSI key (technical_scan --psi) or field CSV". Then comes the two lines that end every partial audit:
coverage: 6 of 7 categories measured
HEALTH SCORE: withheld - not every category has a measured sourceThat is the honesty rule doing exactly what it is for. Composite the seven rows anyway and the report would open with something like "site health: 71/100", and the room would stop reading at the number. Six rows with quoted sources plus one named missing input is an answer a meeting can argue with. Notice the provenance: no API key, no paid data, and the only gap is a category that needs the CWV field data nobody gets for free - the full-data path is the Google Data post in the series.
The artifacts. Under auspia-audit/ in the working directory: the report, the action plan (measured rows in Phase 1, data-required rows in Phase 2), the JSON envelope, and findings/ with one file per scanner, verbatim including any failures. Run the same command on the same URL next month and watch which rows move. The run above is the baseline.
Where the judgment goes
The composer gives you the board. Three decisions decide whether the audit changes anything.
Argue with rows before you argue with the score. A board where every row names its source is a board that survives a meeting, and the audit's job in that meeting is not to win but to be checkable. When someone challenges a verdict, you open the finding file and look at the raw output with them. If a row's verdict came from a scanner whose input was wrong, re-run with the right input - that is the honest correction.
The PENDING rows are the audit's second act. An audit that stops at "what is visible" is half done. The PENDING rows name the inputs that would measure the rest - a GSC CSV for traffic and indexation, a link inventory for authority, a SERP capture for page-type fit, a CWV key for performance. An audit run with everything it needs is an audit that gave its owner homework; an audit that reports the data it lacks is the one that gets the data.
Make it a loop. The composer is rerunnable by design. The minute it takes to run is the cheapest measurement instrument in this series. Run it after any deployment, run it monthly, run it before and after the phase that your plan names. The value of a board is not the first print; it is watching rows move.
Concretely, an audit report that says:
Board: 4 of 7 categories measured; Performance, Schema and AI Search Readiness are PENDING (PSI key, - sitemap scan, GSC CSV).
is good: it states a coverage the reader can reproduce. A report that says:
Site health: 62/100.
without the rows beneath is the transaction. It cannot be challenged, and therefore it cannot teach.
Troubleshooting
Symptom | Cause | Fix |
|---|---|---|
Inventory says | Pointing at the wrong skills directory | Re-run with |
Every board row says | No | Install the posts you need first; each row names its post. A composer with zero skills installed audits nothing and says so |
The whole run aborts with "an audit starts at the server" | URL unreachable, 403, TLS failure, or a typo | Check the URL in a browser, then retry. Do not pass a saved HTML file and call it an audit |
One row says | The scanner hit its 90-second cap or crashed | Open |
Board says | Performance is PENDING by design | The row names its input: the PSI key via |
Health score still withheld even though every row says OK | That is the rule, not a bug | PENDING rows count as not-measured. Add the input the row names and the score prints on the next run |
| It is the original claude-seo envelope | A generator that consumed the original envelope reads this one - map the same top-level keys |
Scanners fail on Windows | The subprocess uses the same python3 as your shell | Install python3 and use the same paste-and-run path shown in each skill's post |
The old run's folder keeps reappearing | Re-runs overwrite | Intentional: the audit is rerunnable, and re-running is the point |
Install this skill with one paste
Copy everything between the markers below and paste it into your Codex session. It reads the two code blocks from this page, writes them into the right place, runs the smoke test on your site, and reports what it found. (If you are using Claude Code instead, paste the same text; it handles the same instructions.)
<PASTE_TO_CODEX>
You will install a Codex skill. Read the two code blocks in the page you were pasted from: the block that starts with the YAML frontmatter `name: codex-seo-audit` and the block that starts with `#!/usr/bin/env python3` (which contains the string `fullsite_audit.py`).
1. Create the directory ~/.codex/skills/codex-seo-audit/scripts.
2. Save the YAML block to ~/.codex/skills/codex-seo-audit/SKILL.md (only the frontmatter and body; do not include the fence lines).
3. Save the Python block to ~/.codex/skills/codex-seo-audit/scripts/fullsite_audit.py (keep the shebang and everything after it).
4. Run: python3 ~/.codex/skills/codex-seo-audit/scripts/fullsite_audit.py https://example.com
5. If example.com fails to fetch, try the user's real site when they provide its URL.
6. Report back: how many series skills were found inline, how many of the 7 board categories are measured, and whether the health score was withheld.
Do not modify any existing skill directories. Do not touch ~/.codex/skills/ outside the codex-seo-audit directory. Timebox the smoke test to one run unless the user asks for more.
</PASTE_TO_CODEX>FAQ
Is this skill a replacement for the others? No, and that is exactly why it exists. It is the conductor. Each skill still does its own job and needs its own inputs; the composer's job is to run the ones that accept a URL, inventory the one that need private data or files, and assemble their verdicts without re-scoring them. The composer with zero series skills installed audits nothing and says so.
Why withhold the health score when one category is missing? Because a composite number with a missing component silently implies a measurement that does not exist. Seven weighted rows with one PENDING become, in the reader's head, a 55 that might have been a 70. The printed answer in that situation is coverage plus rows, which is an answer the audience can act on. The original scorecard's weights are kept; only the premature sum is refused.
Can I run it on a competitor's site? Yes, with the same caveat as the rest of the series: everything it reports is public evidence, so the board is legal to build and fair to compare. You will quickly find that the interesting differences live in the PENDING rows - their internal data is the part you cannot see, and the board names it.
What if one scanner keeps timing out? The run continues and the row says TIMEOUT. Check the finding file: the scanner's error line shows whether it was a fetch failure (try again, the site may be rate-limiting) or something structural (the page is a JavaScript shell; see the corresponding post's note about serving rendered HTML).
Does this run the paid stuff from the original project? No. The original audit spawns DataForSEO and Google-API agents when credentials exist. This composer deliberately does not reach out for paid data: the optional rows are PENDING with the input named, and the full-data audit (GSC CSV, SERP capture, CWV key) is the article on data sources in this series. No key, no cost, no invented numbers.
I installed three skills, ran this, and the board is mostly PENDING. Is that bad? That is the truth about three installed skills. The board's PENDING rows tell you which posts to install next, and the installed rows are the ones fully audited right now. The alternative - a board that pretends audit coverage - is exactly the theater this composer exists to avoid.
This is the final part of the Codex SEO Skills series, in which each post installs one working SEO skill into Codex. The post before this, SEO strategy planning, explains how to turn this board into a
That was the finale: post 19 of 20. You now have the whole stack, from the readiness check to this composer. If you missed any step, the series roadmap lists all 20 posts.
Previous in the series: How to Set Up Codex for SEO Strategy Planning (Full SKILL.md Included).
plan with phases and gates; the competitor pages post feeds the competitive tier. If you have been installing along, you now have the whole stack, and the composer that runs it with one command and one honest board.
Author: Bennett Hayes, Full-Site Audit Lead at Auspia. Bennett verifies SEO audit outcomes across agencies and writes about audit design and data integrity.
Based on the open-source claude-seo project (MIT, AgriciDaniel). Adapted for Codex with a new composer script, a rewritten SKILL.md, and zero non-stdlib dependencies.




