How to Set Up Codex for SEO Strategy Planning (Full SKILL.md Included)

Key takeaways

A ready-to-run Codex skill that recons a live site first (title, H1s, JSON-LD, robots, sitemap inventory), then builds a measured SEO strategy: six industry templates, four phased roadmaps with exit gates, and a KPI table where every baseline row has a real source.

The contract

Who should use this

Anyone asked to produce an SEO strategy, a content plan, a content calendar, or a site-architecture plan

The finished outcome

A recon card of the live site plus six deliverable documents: strategy, competitor analysis, content calendar, roadmap, site structure, KPI targets

What you need before you start

Python 3 and the URL of the site (a new site is fine; the skill handles it as new-site mode)

Time to install and verify

About 10 minutes

Definition of done

Every baseline number in the KPI table comes from the recon output or a labeled manual row, and you could point at the exact source of every projected figure in the roadmap

The one-paragraph version

Most SEO plans are written the wrong way around. The plan arrives with goals ("3x traffic in 12 months") and the strategy is invented to justify them, because nobody measured anything before writing. This skill reverses the order. It starts with a tiny recon script that reads the live site and prints the facts a plan can honestly build on: title length, meta description, H1 count, image alt coverage, JSON-LD types, security headers, robots.txt, and the full sitemap inventory with URL counts and lastmod coverage. Then it hands you the methodology: five phases (discovery, competitive analysis, architecture, content strategy, technical foundation), six industry templates, five deliverables, and a four-phase implementation roadmap where every phase has an exit gate. The KPI table is the part that keeps the plan honest: each baseline row must come from a source, and the recon names what a public crawl can feed and what it cannot, so nobody fills traffic numbers with imagination.

The baseline trap

You have been handed the strategy exercise before. Someone says "write us an SEO plan for the next year," and the room needs the plan to look confident, so the plan invents numbers. Traffic growth of 3x. Domain authority from 30 to 45. "We will target 80 keywords with medium difficulty." These numbers appear plausible in the meeting, and they collide with reality three months later when the report says the baseline was never measured.

There is a cheaper way to get caught. Any public crawler can read the site's own pages and its own sitemap, and the facts it returns are the truth about the current state:

  • The homepage has 62 title characters when the display truncates around 60.
  • Seven H1 tags on one page that should have one.
  • None of the four standard security headers present.
  • JSON-LD that describes the business correctly but only sits on the homepage.
  • 187 URLs in a five-file sitemap index, where 127 of them are blog posts.
  • A robots.txt that points at no sitemap at all.

That is the opening of a plan that will hold up, because it starts with the site as it is. The strategy then answers the only question that matters: what order do you fix these things in, and what does "done" mean at each step?

What the skill actually does

Three movements, one rule.

1. Measure first. The script recons the homepage, robots.txt, and sitemap. It reports what public data can legally feed a KPI baseline (sitemap URL count as the indexed-pages proxy, with an explicit warning that GSC actuals differ), and prints a mapping table for the rows that are not public: organic traffic, keyword rankings, Core Web Vitals, domain authority, review footprint. Those stay manual rows until a real source exists. It also prints what is NOT publishable from a recon, which is the engine of the honesty rule.

2. Apply the methodology. The SKILL.md holds the five phases, six industry templates (SaaS, local service, e-commerce, publisher, agency, and a generic fallback), the contract for each of the five deliverables, the four-phase roadmap with windows and exit gates, and the content-strategy rules (publish-order by gap size, capacity estimates that name who writes, an E-E-A-T plan written at planning time, pages cross-linked at planning time).

3. Gate every phase. The roadmap phases (Foundation, Expansion, Scale, Authority) each carry an exit gate so a phase cannot close without evidence: re-running the recon shows clean headers and one H1, 20-30 pages live and indexed, first authoritative references, brand queries growing. The plan says what counts as done, and the plan is the thing that changes when the evidence says it should.

The SKILL.md below spells out the phases, the templates, the KPI mapping, and the error cases. The script is next, then the workflow.

markdown
---
name: codex-seo-plan
description: Use when the user asks for an SEO strategy, an SEO plan, a content or keyword strategy, a content calendar, a site architecture plan, a phased implementation roadmap, or a baseline+KPI target set for a new or existing website. Recons the site first so every baseline row in the plan comes from evidence rather than guesswork.
---
# Strategic SEO Planning

A plan is only as honest as its baseline. Most SEO plans die at the second
review, and it is usually because the targets were written from imagination
("we will grow organic traffic 3x") instead of from evidence (the site has
187 URLs in five sitemap files, zero security headers, and seven H1s on the
homepage). Every plan this skill produces starts with a measured baseline,
and every projected number is labeled as a projection.

## Commands

```
python3 plan_recon.py <url> [--json]
```

- `plan_recon.py` recons the site and prints what the plan's KPI table can
  legally use from public sources: title length, meta description, H1/H2
  counts, image alt coverage, JSON-LD types on the homepage, security
  headers, robots.txt sitemap/disallow lines, and the sitemap index with
  per-file URL counts and lastmod coverage.
- `--json` prints the same data machine-readable for larger scripts.
- Run it on the live site once. Behind-the-CMS numbers (GSC imports,
  analytics, review counts) are internal; the script prints the exact rows
  that stay empty until you import them.

## The five phases (what the plan walks through)

| Phase | Question it answers | Evidence it needs |
|-------|---------------------|-------------------|
| 1. Discovery | What is this business, who buys, what is the goal? | Business type, audience, goals; recon, GSC CSV |
| 2. Competitive analysis | Who are the 5, and what do they do better? | Top-5 domains, their sitemaps/schema/review footprint |
| 3. Architecture design | What is the URL map and the pillar layout? | Recon sitemap inventory, internal-link plan |
| 4. Content strategy | What is published first, at what cadence? | Gaps vs competitors, page counts, E-E-A-T plan |
| 5. Technical foundation | What has to work before any of the above matters? | Security, CWV, schema plan, AI-search readiness |
| Roadmap | In which order, with which gates? | Phase exits, review samples, batch sizes |

## Recon-first rule

Every row of the KPI table must come from one of two places: the recon
output (public data), or an explicitly labeled manual row. The recon prints
this mapping so the plan author cannot drift:

| KPI row | Where the baseline comes from |
|---------|--------------------------------|
| Indexed pages | Sitemap URL count from recon (proxy; GSC actuals differ - import the CSV) |
| Organic traffic | Internal (GSC CSV per `codex-seo-google`) - no public source |
| Keyword rankings | Internal or paid tool (DataForSEO-style OCI optional) - not invented |
| Core Web Vitals | PSI/CrUX free key or manual; never a guessed score |
| Domain authority | Paid tool or your own link inventory (`codex-seo-backlinks`) |
| Review footprint | G2/Capterra/Trustpilot pages; the numbers on them are the numbers |

If a row has no source, the plan says "manual row - attach source" and
moves on. A plan that gates a quarter on a number nobody measured is a
policy document, not a plan.

## Industry templates (pick zero, then adapt)

| Template | What it changes |
|----------|-----------------|
| SaaS/software | Free tier to paid ladder, comparison pages, changelog as SERP surface |
| Local service | GBP layer, service-area pages (only with real differentiation - see `codex-seo-programmatic`), review volume |
| E-commerce | Product schema ladder, category hubs, UCP-awareness (no invented protocol claims) |
| Publisher/media | Index bloat risk at scale, E-E-A-T for authors, topic clusters with revenue intent |
| Agency/consultancy | Service pages vs case studies, personal-name E-E-A-T, GEO for client niches |
| Generic | Pillars + supporting posts, contact-to-conversion path, review proof |

Default to the generic template when the business type is unclear, and say
so in the output. Template fit is a judgment call the plan must document,
not a rule the plan can hide behind.

## Deliverables (the contract for each)

1. `SEO-STRATEGY.md`: business context, the measured baseline (recon rows),
   goals, the chosen template, phases with owners.
2. `COMPETITOR-ANALYSIS.md`: top-5 with their sitemap sizes, schema types,
   review footprints, content gaps (each gap stated as "they have X, and
   their page is linked from Y"), authority estimate labeled as estimate.
3. `CONTENT-CALENDAR.md`: first 30 days by page type, then a cadence table
   (weekly/fortnightly), each entry tied to a gap, with a publishing
   capacity assumption stated, and staged-rollout batch sizes per the
   programmatic gates (50-100 pages per batch, 2-4 weeks of observation
   before scaling).
4. `IMPLEMENTATION-ROADMAP.md`: the 4 phases below, with exit criteria.
5. `SITE-STRUCTURE.md`: URL hierarchy from the recon sitemap inventory,
   pillar-to-support map, internal-link plan (hub/spoke), breadcrumbs.
6. `KPI-TARGETS.md`: baseline row (evidence), then 3/6/12-month columns
   labeled projected scenario, with the assumptions each projection names.

## Implementation roadmap (4 phases, with gates)

| Phase | Window | Scope | Exit gate |
|-------|--------|-------|-----------|
| 1. Foundation | weeks 1-4 | Server/HTTPS/security headers, core pages (home/about/contact/services), essential schema, analytics + GSC ownership | Recon re-run shows clean headers, H1=1 on home, sitemap live, GSC CSV imported |
| 2. Expansion | weeks 5-12 | Primary page content, blog goes live, internal linking, local layer (if applicable) | 20-30 pages live, linked from hubs, indexed count in GSC growing |
| 3. Scale | weeks 13-24 | Deeper content, links + outreach, GEO optimization, CWV work | First authoritative references; CWV in green; batch rollout after observation |
| 4. Authority | months 7-12 | Thought leadership, media/PR, advanced schema, continuous improvement | Brand queries growing; AI sources referencing the site; review count on plan |

Phase windows are defaults, not laws. The gates are: the plan says what
counts as done, and the phase cannot close without the gate meeting its
evidence.

## Competitive analysis (top-5 method)

1. Pick 5: the sites you lose to on your own head terms, plus one from the
   next tier that has grown recently (the plan should learn from the mover,
   not just the incumbent).
2. For each: run the recon script against their site, and note their
   sitemap size, schema type set, review footprint, and content surface.
3. Gap = intent they own with a page you have no answer for; a gap is not
   "they rank higher"; it is "they have a structured answer and we do not".
4. E-E-A-T pass: author names, bylines, citations, entity mentions in their
   authored content. If theirs repeat a single author's name and yours do
   not, that is a gap.
5. Authority estimate: label it. A paid tool number, or yours from
   `codex-seo-backlinks`; nothing else.

## Content strategy rules

- Publish-order by gap size first, then by closeness to the money intent.
  A popular topic nobody needs is a calendar filler.
- Estimate counts honestly: `content-capacity × weeks` where capacity
  states who writes. Do not leave capacity implicit; it is the single
  biggest cause of phantom roadmaps.
- E-E-A-T plan as first-class: author bios, page-level bylines, dates
  (add one; freshness needs a date visible on the page).
- Cross-link planned pages at planning time, not after publishing - the
  link map is part of architecture, not a content afterthought.

## Error handling

| Scenario | Action |
|----------|--------|
| Unrecognized business type | Use the generic template, state that no industry template matched |
| No live URL provided | New-site planning mode: skip recon rows and mark the baseline column "not yet measurable"; do not invent them |
| Recon fetch fails (403/timeout/SSL) | Report it; the plan starts at Phase 1 (fix the server) and the baseline row stays manual |
| Sitemap missing | Note it as a Phase-1 task; the indexed-pages baseline is "no public sitemap" |
| Competitor analysis with 1-2 competitors found | Run on what exists; state the small sample; the plan avoids claims about "the market" |
| GSC CSV not available | Baseline columns labeled manual; projections keep the assumption line |
python
#!/usr/bin/env python3
"""Recon a site before writing an SEO plan: what the plan's baseline rows can legally use.

stdlib only. Usage:
  python3 plan_recon.py <url> [--json]
"""
import gzip
import json
import re
import sys
import urllib.request

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 = 20
CAP = 6 * 1024 * 1024
TODAY = __import__("datetime").date.today().isoformat()
SITEMAP_CAPS = {"children": 8, "urls": 10000, "lastmod_sample": 400}


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)
            final = r.geturl()
            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 ld_types(html):
    counts = {}
    stack_objs = []
    for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
                         html, re.I | re.S):
        try:
            data = json.loads(m.group(1).strip())
        except Exception:
            continue
        stack_objs.append(data)
    while stack_objs:
        node = stack_objs.pop()
        if isinstance(node, dict):
            t = node.get("@type")
            for x in (t if isinstance(t, list) else [t]):
                if isinstance(x, str):
                    counts[x] = counts.get(x, 0) + 1
            stack_objs.extend(v for v in node.values() if isinstance(v, (dict, list)))
        elif isinstance(node, list):
            stack_objs.extend(node)
    return counts


def recon_page(url):
    out = {"url": url}
    html, hdr, err = fetch(url)
    if err or html is None:
        out["status"] = "fetch-error"
        out["errors"] = [err or "no page"]
        return out
    out["status"] = 200
    out["final_url"] = url
    out["scheme"] = url.split(":")[0] + ":"
    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)
    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_description"] = {"found": md is not None,
                               "chars": (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),
                     "alt_coverage_pct": round(100 * len(alt) / len(imgs), 1) if imgs else 100.0}
    out["jsonld_types"] = dict(sorted(ld_types(html).items(), key=lambda kv: -kv[1]))
    out["headers_report"] = {
        k: "present" for k in ("strict-transport-security", "x-content-type-options",
                               "content-security-policy", "referrer-policy") if hdr and k in hdr}
    out["scheme"] = out.get("scheme")
    canon = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]*href=["\']([^"\']+)', html, re.I) \
        or re.search(r'<link[^>]+href=["\']([^"\']+)["\'][^>]*rel=["\']canonical', html, re.I)
    out["canonical"] = canon.group(1) if canon else None
    return out


def parse_xml_text(body):
    """Namespace-insensitive <loc>/<lastmod> extraction from a sitemap file."""
    locs = re.findall(r"<loc>(.*?)</loc>", body, re.S)
    lastmods = len(re.findall(r"<lastmod>(.*?)</lastmod>", body, re.S))
    return locs, lastmods


def recon_sitemap(base, robots):
    """robots: raw text or None. Returns dict with rows."""
    info = {"found": False}
    sm_url = None
    if robots:
        sm = [l.strip().split(None, 1)[-1] for l in robots.splitlines()
              if re.match(r"(?i)^sitemap\b", l) and ":" in l]
        sm_url = sm[0] if sm else None
        dis = [l for l in robots.splitlines() if re.match(r"(?i)^disallow\b", l)]
        info["robots_sitemap_lines"] = len(sm)
        info["robots_disallows"] = len(dis)
    if not sm_url:
        sm_url = base + "/sitemap.xml"
    body, _, err = fetch(sm_url)
    if err or body is None:
        info["errors"] = [err or "no body"]
        return info
    info["found"] = True
    info["file"] = sm_url
    info["kind"] = "index" if "<sitemap>" in body else "urlset"
    if info["kind"] == "index":
        children = re.findall(r"<loc>(.*?)</loc>", body, re.S)
        kids = []
        total = 0
        for c in children[:SITEMAP_CAPS["children"]]:
            cb, _, cerr = fetch(c)
            if cb is None:
                kids.append({"url": c, "error": cerr or "no body"})
                continue
            locs, lastmods = parse_xml_text(cb)
            n = min(len(locs), SITEMAP_CAPS["urls"])
            lt = min(lastmods, SITEMAP_CAPS["lastmod_sample"])
            kids.append({"url": c, "urls": n,
                         "lastmod_pct": round(100 * lt / min(len(locs), SITEMAP_CAPS["lastmod_sample"]), 1)
                         if locs else None})
            total += n
        info["children"] = kids
        info["urls_total"] = total
    else:
        locs, lastmods = parse_xml_text(body)
        n = min(len(locs), SITEMAP_CAPS["urls"])
        info["urls_total"] = n
        sample = min(len(locs), SITEMAP_CAPS["lastmod_sample"])
        info["lastmod_pct"] = round(100 * min(lastmods, sample) / sample, 1) if sample else 100.0
    return info


def report(page, robots, smap, base):
    out = ["SITE RECON: " + page["url"], "=" * 40]
    if page.get("status") != 200:
        out.append("STATUS: fetch-error - " + "; ".join(page.get("errors", [])))
        out.append("Rule: a plan for an unreachable site starts at Phase 1 (Fix the server), "
                   "not at keyword research.")
        return out
    out.append("final URL: " + page.get("final_url", page["url"]))
    out.append("HTTPS page served over: " + page["scheme"])
    out.append("www → canonical: " + (page.get("canonical") or "no canonical tag"))
    for k in ("strict-transport-security", "x-content-type-options",
              "content-security-policy", "referrer-policy"):
        out.append(f"security header {k}: " + ("present" if k in page.get("headers_report", {})
                                               else "missing"))
    out.append("title chars (ideal 30-60): " + str(page["title"]["chars"]))
    got = page["meta_description"]
    out.append("meta description: " + ("present " + str(got["chars"]) + " chars"
                                       if got["found"] else "MISSING"))
    out.append("H1 count (ideal 1): " + str(page["h1"]) + "   h2 count: " + str(page["h2"]))
    im, alt, cov = page["images"]["total"], page["images"]["with_alt"], page["images"]["alt_coverage_pct"]
    out.append(f"images: {im} total, {alt} with alt = {cov}% covered")
    t = page["jsonld_types"]
    out.append("JSON-LD types: " + (", ".join(f"{k}: {v}" for k, v in t.items()) or "NONE") +
               ("   |   (homepage only; audit per page type with codex-seo-schema)" if t else ""))
    out.append("robots.txt: " + (
        f"{smap.get('robots_sitemap_lines', 0)} sitemap line(s), "
        f"{smap.get('robots_disallows', 0)} disallow rule(s)" if robots is not None else "not fetched"))
    if smap.get("found"):
        line = f"sitemap: {smap['file']}  ({smap['kind']})  URLs: {smap.get('urls_total', 0)}"
        if smap.get("lastmod_pct") is not None:
            line += f"  lastmod: {smap['lastmod_pct']}%"
        out.append(line)
        for k in smap.get("children", []):
            if "error" in k:
                out.append(f"  child file: {k['url']}  ERROR {k['error']}")
            else:
                lt = f"  lastmod {k['lastmod_pct']}%" if k.get("lastmod_pct") is not None else ""
                out.append(f"  child file: {k['url']}  URLs: {k['urls']}{lt}")
    else:
        out.append("sitemap: NOT FOUND (" + "; ".join(smap.get("errors", ["no robots line, /sitemap.xml failed"])) + ")")
    out.append("")
    out.append("KPI-baseline mapping (what this recon can legally feed):")
    out.append("  Indexed pages proxy = sitemap URL count above; GSC actuals differ - import CSV "
               "(codex-seo-google).")
    out.append("  Traffic / keyword rankings = internal data; not on the public web.")
    out.append("  Core Web Vitals = PSI or CrUX key or manual (codex-seo-google).")
    out.append("  Domain authority / review footprint = a paid or 3rd-party tool, or your own ")
    out.append("   link inventory (codex-seo-backlinks).")
    out.append("NOT PUBLISHABLE from this recon: any KPI row not mapped above. Leave it as a "
               "manual row until a real source exists.")
    return out


def main():
    args = [a for a in sys.argv[1:]]
    json_mode = "--json" in args
    urls = [a for a in args if not a.startswith("--")]
    if not urls:
        print("usage: plan_recon.py <url> [--json]", file=sys.stderr)
        return 2
    url = urls[0]
    base = url.split("//")[0] + "//" + url.split("//")[1].split("/", 1)[0] if "//" in url else url
    page = recon_page(url)
    robots, robots_err = None, None
    rb, _, rerr = fetch(base + "/robots.txt")
    if rb is not None and "<html" not in rb.lower()[:400]:
        robots = rb
    else:
        robots_err = rerr or "no robots.txt"
    if robots is None:
        robots = ""
    smap = recon_sitemap(base, robots or None)
    if json_mode:
        print(json.dumps({"page": page, "robots_error": robots_err, "sitemap": smap}, indent=1))
        return 0
    print("\n".join(report(page, robots, smap, base)))
    print("recon run: " + TODAY + " | source URLs are public pages of the target site")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Install the skill

Three commands, then a smoke test.

bash
# 1) create the skill directory
mkdir -p ~/.codex/skills/codex-seo-plan/scripts

# 2) save the SKILL.md (paste the first code block above into the file below)
nano ~/.codex/skills/codex-seo-plan/SKILL.md

# 3) save the script (paste the second code block above)
nano ~/.codex/skills/codex-seo-plan/scripts/plan_recon.py

# 4) smoke test against a site you can reach
python3 ~/.codex/skills/codex-seo-plan/scripts/plan_recon.py https://your-site.com

If 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. That is the cheapest possible self-test, because you already know the answers. Then open your current strategy document (if one exists) and look for the first KPI number in it: if you cannot say where that number came from, this skill is about to earn its keep.

bash
python3 ~/.codex/skills/codex-seo-plan/scripts/plan_recon.py https://your-site.com

Confirm three things specifically: the H1 count row says 1 (or tells you loudly that it does not), the sitemap row lists your URL count and a lastmod percentage, and the KPI-baseline mapping block prints at the bottom. If the sitemap row says NOT FOUND, that is not a bug in your install; it is the first item for Phase 1.

A real run: a two-location pizza restaurant

There is nothing synthetic about the example below. This is the actual output of the script on 2026-09-01, run against the site of Pequod's Pizza, a Chicago deep-dish restaurant with two locations. The domain has been used in this series before, which is exactly why it is a good case: it is a small, real business with a small, real site. The recon takes under a minute, uses no API keys, and costs nothing.

SITE RECON: https://www.pequodspizza.com ======================================== final URL: https://www.pequodspizza.com HTTPS page served over: https: www → canonical: https://pequodspizza.com/ security header strict-transport-security: missing security header x-content-type-options: missing security header content-security-policy: missing security header referrer-policy: missing title chars (ideal 30-60): 62 meta description: present 161 chars H1 count (ideal 1): 7 h2 count: 9 images: 15 total, 15 with alt = 100.0% covered JSON-LD types: ImageObject: 3, Organization: 2, Restaurant: 2, PostalAddress: 2, WebSite: 1, SearchAction: 1, Article: 1, Person: 1 | (homepage only; audit per page type with codex-seo-schema) robots.txt: 0 sitemap line(s), 1 disallow rule(s) sitemap: https://www.pequodspizza.com/sitemap.xml (index) URLs: 187 child file: https://pequodspizza.com/post-sitemap.xml URLs: 127 lastmod 100.0% child file: https://pequodspizza.com/page-sitemap.xml URLs: 44 lastmod 100.0% child file: https://pequodspizza.com/product-sitemap.xml URLs: 9 lastmod 100.0% child file: https://pequodspizza.com/layouttag-sitemap.xml URLs: 4 lastmod 100.0% child file: https://pequodspizza.com/productcat-sitemap.xml URLs: 3 lastmod 100.0%

KPI-baseline mapping (what this recon can legally feed): Indexed pages proxy = sitemap URL count above; GSC actuals differ - import CSV (codex-seo-google). Traffic / keyword rankings = internal data; not on the public web. Core Web Vitals = PSI or CrUX key or manual (codex-seo-google). Domain authority / review footprint = a paid or 3rd-party tool, or your own link inventory (codex-seo-backlinks). NOT PUBLISHABLE from this recon: any KPI row not mapped above. Leave it as a manual row until a real source exists. recon run: 2026-09-01 | source URLs are public pages of the target site

Reading the run

Every row above is a finding, and the findings order the plan. A few matter more than the rest.

  • The homepage has seven H1s. The site runs on WordPress (the sitemap file names are the giveaway: post-sitemap.xml, page-sitemap.xml, product-sitemap.xml). The theme is almost certainly rendering a logo and header text as H1 tags on every page. This is the kind of thing a plan should catch in Phase 1, not discover in an audit twelve months in. It takes a theme tweak to fix, it touches every page at once, and it is the cheapest structural win in the whole list.
  • Every security header is missing. Strict-Transport-Security, x-content-type-options, content-security-policy, referrer-policy: all four absent. On a site with an online ordering surface, these are a one-hour setting in the hosting panel or a WordPress security plugin. They are Phase-1 items for the reason that they cost nothing and protect everything.
  • The title is 62 characters, and the meta is 161. Both sit just outside the display windows, which means Google is already rewriting or truncating them. Fixing these is two edits.
  • Two restaurants, correct schema, presented once. The JSON-LD set is genuinely good for a local business: Restaurant (2), Organization (2), PostalAddress (2), ImageObject (3), plus WebSite, SearchAction, Article, Person. The gap is placement, not existence: the homepage stays in sight. The schema audit per page type is a follow-up from codex-seo-schema, but the plan already knows the first pass belongs to menu and location pages.
  • The robots.txt has no sitemap line. The sitemap index exists and the script found it anyway, because the fallback path checks the standard location. Google will find it too, but the signal belongs in robots.txt, and so does the plan's note.
  • 187 URLs, and 127 of them are posts. The content surface is healthy (44 pages, 9 products, 3 product categories, 4 layout tags). The strategic question the plan must answer is what those 127 posts do for ordering decisions, and whether the URL count is meaningful or just WordPress archive noise. The recon gives the inventory; the plan decides what to do with it.

What the plan for this site actually says

Build case, in order. The recon printed above IS the KPI-baseline mapping; here is what the plan makes of it.

Phase 1, weeks 1-4 (Foundation). Add the four security headers. Fix the H1 structure (one per page). Trim title to under 60 and rewrite the meta to a clean 155. Add the sitemap line to robots.txt and submit the site in Search Console. Install an analytics setup if one does not exist. The exit gate: re-run the recon and confirm clean headers, H1 count 1, and the robots sitemap line present.

Phase 2, weeks 5-12 (Expansion). With 127 posts and 44 pages, this is not a content-creation phase; it is a structure phase. Reorganize the blog around the intents the restaurant actually needs (menu items, Chicago deep-dish comparisons, ordering questions), link every recipe and post back to a menu or location page, and build the location pages with the schema already present (the two Restaurant blocks were found on the homepage, which is where they should not be). The internal-link map is designed now, not after publishing. Exit gate: the top 20 posts link to a menu or location page, and new page counts in GSC are reported against the 187 baseline.

Phase 3, weeks 13-24 (Scale). This is where the manual rows enter. Review footprint from the platforms the restaurant actually has listings on, local citations checked against a single consistent NAP, and GEO attention on questions like "best deep dish in Chicago" where AI answers now sit above the classic local pack. Core Web Vitals gets measured (that row is a manual row too; the recon cannot produce it). Exit gate: first external references from local publications, CWV green on the ordering path.

Phase 4, months 7-12 (Authority). Local PR and food-press coverage that names the restaurant, structured data extended to menu and FAQ, and a review cadence with an owner. Exit gate: brand queries growing in GSC, AI search results citing the site by name, review count on plan.

And the KPI table starts with a rule, not with numbers. Indexed pages baseline: 187, with "(proxy from sitemap; import GSC CSV for actuals)" next to it. Organic traffic baseline: manual row. Keyword rankings: manual row. Domain authority: manual row (or a paid tool's number, labeled). Core Web Vitals: manual row. The 3/6/12-month columns are labeled projections, and each projection names the assumption that produces it. A plan that quotes a baseline without a source gets flagged; a plan that says "we measured 187 URLs in five sitemap files and this is the part we did not measure" is the one a founder will fund.

Where the judgment goes

The script gives you a measured base. Three decisions decide how good the plan is.

Order by what is true, not by what is fashionable. The recon above says nothing about keywords, which is precisely its value: it forces Phase 1 to be about headers, H1s, robots.txt, and a title crawlable by Google, not about a keyword list someone bought from a tool. When a site is absent four security headers, the cheapest traffic work available is on the page, not in the market.

Name the capacity. Content calendars die on implicit assumptions. "Two posts a week for the next year" and "a hundred pages of programmatic landing pages" are very different promises with a very different cost profile. The skill forces the content-capacity x weeks line to state who writes, and the roadmap gates scale only after observation. This is the single most reliable way to keep a plan alive: it projects budget, not enthusiasm.

Use the manual row, and say so. The recon ends with the NOT PUBLISHABLE block for a reason. It will be tempting to put a traffic guess in the baseline column of the KPI table so the plan looks complete. The honest row is two words longer and survives contact with the first report you run.

Concretely, a sentence like:

Phase 1 exits when the recon shows one H1, four security headers, and a robots.txt sitemap line.

is good: it is checkable with one command. A sentence like:

We expect organic traffic to triple in the first year.

needs a baseline, an assumption, and a named measurement method. Write the first kind, and when you want the second kind, attach all three.

Troubleshooting

Problem

What happened

Fix

STATUS: fetch-error - HTTP 403

The site blocks non-browser agents, or the URL needs a final /.

Check the URL in a browser first. If it genuinely blocks crawlers, the plan starts at Phase 1 (fix the server) and the recon row says so.

sitemap: NOT FOUND

No robots.txt sitemap line and no sitemap.xml at the default location.

No sitemap is a Phase-1 task, not an error. Note it in the plan and move on; the indexed-pages baseline says "no public sitemap."

robots: not fetched or disclaimers after fetch

A robots file that serves HTML (common on misconfigured hosts).

The script detects it. Treat it as the same Phase-1 item; a robots.txt serving HTML is worth fixing before the plan asks for it.

The plan for your business type is obvious

The six templates cover the common cases, but not every case.

Pick the closest template and state the substitution in the output. The skill says to default to generic and say so.

The recon works but the plan feels generic

It is generic because the site has nothing to say yet.

Run the recon on 5 competitors too (their sitemaps, schema types, review footprints), then rebuild the gap analysis from those rows.

You have no URL because the site is new

New-site mode.

The recon blocks say "not yet measurable" and the plan skips to Phase 1 with a launch checklist. Do not invent baselines.

You pasted the SKILL.md into Codex and nothing happens

Codex triggers skills by description match.

Ask it explicitly by name: $codex-seo-plan or "use the SEO plan skill". List installed skills with /skills.

Windows paths

The script is pure Python 3; the only difference is how you write ~.

Use %USERPROFILE% instead of ~, or just run the script from the folder the files are in.

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.)

Code
<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-plan` and the block that starts with `#!/usr/bin/env python3` (which contains the string `plan_recon.py`).

1. Create the directory ~/.codex/skills/codex-seo-plan/scripts.
2. Save the YAML block to ~/.codex/skills/codex-seo-plan/SKILL.md (only the frontmatter and body; do not include the fence lines).
3. Save the Python block to ~/.codex/skills/codex-seo-plan/scripts/plan_recon.py (keep the shebang and everything after it).
4. Run: python3 ~/.codex/skills/codex-seo-plan/scripts/plan_recon.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: the H1 count row, the sitemap URL count, and whether the KPI-baseline mapping block printed.

Do not modify any existing skill directories. Do not touch ~/.codex/skills/ outside the codex-seo-plan directory. Timebox the smoke test to one run unless the user asks for more.
</PASTE_TO_CODEX>

FAQ

What makes this plan different from a template I can buy? The methodology (phases, templates, deliverables, roadmap gates) is adapted from an open-source project, and templates are cheap. The difference is the measure-first start: the recon card prints what the site looks like today, the KPI mapping says which rows a public crawl can feed, and the roadmap gates call for re-running that recon. A template gives structure; this gives structure plus a baseline that cannot be argued with.

Do I need a paid keyword tool for this? No. The plan builds its competitive tier from public sitemaps, schema, and review footprints. Keyword data is optional and treated as one input, not the plan: the skill lists a DataForSEO-style integration as an optional step for teams that already pay for it, and the fallback (manual, or your own GSC export) is fully supported. The recon script itself is free and keyless.

My site is new and has no URL. Can I still plan? Yes. New-site mode skips the recon rows and marks them "not yet measurable" instead of inventing numbers. The roadmap then starts at Phase 1 with a launch checklist, and the baseline columns get filled the moment the site exists. This is one of the few times a blank is the correct answer in a plan.

How do I write the KPI table honestly? Short version: baseline rows come from the recon output or from a real source you attach (GSC CSV export, PSI/CrUX report, a paid tool's number labeled as such). Projection columns (3/6/12 months) are scenarios, and each one names the assumption behind it, like this example in the article: "indexed pages proxy: 187 from sitemap; GSC actuals differ." If a row has no source today, it says "manual row" and the plan marks the owner to get one.

Do the four roadmap phases fit big sites and small ones? The phase windows are defaults, not laws; the gates are the honest part. The recon above (a restaurant with 187 URLs) fills Phase 1 the same way a 30,000-product catalog does: fix the technical floor, then expand, then scale after observation. Batch rollout for large sites explains when to run 50-100 pages per batch and observe for 2-4 weeks between batches, so a catalog does not become a scaled-content-abuse problem in the middle of the year.

Is this a plan I hand to a client, or a plan I execute myself? Both. The deliverables are written so a client can read them: strategy, competitor analysis, content calendar, roadmap, site structure, KPI targets. But the phases and gates are written so whoever executes knows when a phase is actually done. The recon script is the equalizer: it is the check you run when a phase claims to be finished.

This is part 18 of the Codex SEO Skills series, in which each post installs one working SEO skill into Codex. The previous post, competitor comparison pages, builds the research desk for the competitive tier of the plan; the programmatic SEO post covers the scale gates the roadmap borrows. If you are writing an SEO plan this year, consider this the post that makes it measurable. The final post, a full-site audit that runs every skill in the series, is next.

Next in the series (post 19 of 20): [How to Set Up Codex for Full-Site SEO Audits (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-audit) - full-site audit that orchestrates everything you installed.

Previous in the series: How to Set Up Codex for Competitor Comparison Pages (Full SKILL.md Included). The full series roadmap lists all 20 posts.

Author: Celia Morgan, SEO Plan Reviewer at Auspia. Celia evaluates SEO strategies and roadmaps, and writes about planning and measurement cycles.

Based on the open-source claude-seo project (MIT, AgriciDaniel). Adapted for Codex with a new recon script, a rewritten SKILL.md, and zero non-stdlib dependencies.

Explore this topic

Keep following the same growth thread