Sitemaps and Robots.txt skill for Codex

Crawl rules Google actually reads the way you meant. Codex separates crawl control from index control before touching either file, then generates sitemaps from your content source with truthful lastmod values. A bundled script validates sitemap limits and formats and tests URLs against robots.txt using Google's group selection, wildcards and longest-match precedence, which Python's own robotparser gets wrong.

  • Growth & SEO
  • $sitemaps-robots
  • 201 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- sitemaps-robots

Installs to ~/.agents/skills/sitemaps-robots/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $sitemaps-robots, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes, fixes and validates XML sitemaps, sitemap indexes and robots.txt — 50,000-URL and 50 MB limits, accurate lastmod, wildcard and longest-match rules, per-crawler groups including AI crawlers — and manages crawl budget on large sites. Use when creating or generating sitemap.xml, editing robots.txt, Search Console reports sitemap errors or "Indexed, though blocked by robots.txt", faceted URLs are eating crawl, or you need to check whether a URL is blocked.

Use it when

  • Creating or splitting sitemap.xml files or a sitemap index
  • Editing robots.txt or debugging what a rule actually blocks
  • Search Console reports sitemap errors or URLs indexed though blocked
  • Faceted navigation or parameters are wasting crawl on a large site

Use something else when

  • You need a full crawl and indexation review of the site: use technical-seo-audit
  • Your sitemap and robots come from Next.js app router files: use nextjs-seo

How to check the result

Run seo_files.py on every sitemap and on the robots.txt with the URLs that must stay crawlable; both exit 0, then the Search Console Sitemaps report shows Success.

The mistake it prevents

Adding a User-agent: Googlebot group for one rule and forgetting that Googlebot then ignores every rule in the * group.

~/.agents/skills/sitemaps-robots/SKILL.md

--- name: sitemaps-robots description: Writes, fixes and validates XML sitemaps, sitemap indexes and robots.txt — 50,000-URL and 50 MB limits, accurate lastmod, wildcard and longest-match… license: MIT ---

Done well: robots.txt blocks only URL spaces that should never be crawled and never blocks anything that must be rendered or de-indexed; every sitemap lists only canonical, indexable 200 URLs with truthful lastmod, and both files are tested before deploy.

Mental model

FileControlsDoes not control
robots.txtCrawling (whether a bot may fetch a URL)Indexing. A blocked URL can still be indexed from links, without content
noindex (meta or X-Robots-Tag)IndexingCrawling. The bot must be allowed to fetch the page to see it
XML sitemapDiscovery and recrawl hintsInclusion. Listing a URL does not force indexing

Workflow

- [ ] 1. Inventory: find existing robots.txt, sitemap generators, CDN/edge rules
- [ ] 2. Decide what should be crawled, indexed and listed per URL pattern
- [ ] 3. Write or generate robots.txt
- [ ] 4. Generate sitemaps from the source of truth
- [ ] 5. Validate both with scripts/seo_files.py
- [ ] 6. Deploy, submit, monitor

1. Inventory

  • Read AGENTS.md, then find generators: grep -rniE "sitemap|robots" --exclude-dir=node_modules -l . Common sources: Next.js app/sitemap.ts/app/robots.ts (see nextjs-seo), next-sitemap, @astrojs/sitemap, WordPress core or SEO plugins, static files in public/, CDN workers that rewrite /robots.txt.
  • Fetch production versions (network needed): curl -s https://www.example.com/robots.txt and every Sitemap: URL it lists. Check robots.txt returns 200 with text/plain.

2. Decide per URL pattern

PatternCrawlIndexIn sitemap
Canonical content pagesallowyesyes
Internal search results /search?q=disallownono
Filter/sort permutations with no search demanddisallow pattern (or canonicalize if few)nono
Filter combos with real demand (e.g. /shoes/red)allowyesyes, as clean URLs
Cart, checkout, account, admindisallownono
Pages being removed from the indexallow + noindex until gonenono
JS, CSS, images, API endpoints needed for renderingallown/ano
Staging/preview hostsauth or noindex header; not robots.txt alonenono

3. robots.txt

# https://www.example.com/robots.txt
User-agent: *
Disallow: /cart
Disallow: /account/
Disallow: /search
Disallow: /*?*sort=
Disallow: /*?*sessionid=
Allow: /search/help$

Sitemap: https://www.example.com/sitemap_index.xml

Matching rules Google applies (RFC 9309):

  • A crawler obeys only the single most specific User-agent group that matches it, and ignores * rules entirely if a named group exists. If you add User-agent: Googlebot, repeat every shared rule inside it.
  • Paths are case-sensitive prefixes; * matches any sequence, $ anchors the end.
  • The longest matching rule wins; on a tie, Allow wins.
  • Crawl-delay is ignored by Google (Bing honors it). Noindex: in robots.txt is not supported. Google reads only the first 500 KiB.
  • Sitemap: lines are global, must be absolute URLs, and may point to other hosts you have verified.
  • Unreachable robots.txt (5xx) can halt crawling for the site; 4xx means “no rules”.

AI crawler controls are separate user agents. Training and search-retrieval bots are distinct; decide per bot with the user, because blocking a search bot removes the site from that product’s answers:

User-agent: GPTBot          # OpenAI model training
Disallow: /

User-agent: OAI-SearchBot   # ChatGPT search results; keep allowed to appear there
Allow: /

User-agent: Google-Extended # Gemini training/grounding; does not affect Google Search
Disallow: /

Verify user-agent tokens in each vendor’s current documentation before shipping; they change. For citation strategy, hand off to ai-search-optimization.

4. Sitemaps

Rules:

  • Max 50,000 URLs and 50 MB uncompressed per file; gzip is fine. Above that, use a sitemap index (max 50,000 child sitemaps; indexes cannot nest).
  • Absolute canonical URLs only, same protocol and host as the canonical tag. No redirects, 404s, noindex pages, parameters you canonicalize away, or blocked URLs.
  • lastmod in W3C Datetime (2026-10-28 or 2026-10-28T09:30:00+00:00), changed only when the main content changes. Setting every lastmod to the build time teaches Google to ignore it. Google ignores <priority> and <changefreq>; omit them.
  • Escape & as &amp; in <loc>. Use UTF-8.
  • Split by type (sitemap-products-1.xml, sitemap-articles.xml) so Search Console shows indexing coverage per section.
  • Generate from the database or content source, not by crawling your own site.
  • Image, video and news extensions exist; add them only when those assets matter for search. Hreflang alternates can live in sitemaps (see hreflang-international-seo).

Minimal generator pattern:

from xml.sax.saxutils import escape

def write_sitemap(rows, path):
    """rows: iterable of (absolute_url, updated_at datetime with tzinfo)."""
    with open(path, "w", encoding="utf-8") as fh:
        fh.write('<?xml version="1.0" encoding="UTF-8"?>\n')
        fh.write('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n')
        for url, updated in rows:
            fh.write(f"  <url><loc>{escape(url)}</loc>"
                     f"<lastmod>{updated.isoformat(timespec='seconds')}</lastmod></url>\n")
        fh.write("</urlset>\n")

5. Validate

# Sitemap structure, limits, lastmod format, host, and robots.txt conflicts (offline on files)
python3 scripts/seo_files.py sitemap public/sitemap.xml --host www.example.com --robots public/robots.txt

# Test specific URLs against robots.txt with Google's precedence rules
python3 scripts/seo_files.py robots public/robots.txt /search/help '/shop?sort=price' /assets/app.js
python3 scripts/seo_files.py robots https://www.example.com/robots.txt /blog/ --agent OAI-SearchBot

Exit code 1 means a sitemap error or a tested URL is blocked. Python’s built-in urllib.robotparser uses first-match prefix logic and ignores wildcards, so do not use it to answer “is this blocked?” questions.

Also check sitemap URLs actually return 200 and self-canonicalize. Sample 50 with network access:

grep -oE '<loc>[^<]+' sitemap.xml | sed 's/<loc>//' | shuf -n 50 | \
  xargs -I{} curl -s -o /dev/null -w '%{http_code} {}\n' {} | grep -v '^200'

(shuf is GNU; on macOS use gshuf or sort -R.)

6. Deploy and monitor

  • Reference sitemaps in robots.txt and submit the index in Search Console (Sitemaps report) and Bing Webmaster Tools. The old Google sitemap ping endpoint is retired; do not build it into deploy hooks. IndexNow notifies Bing, Yandex and others, not Google.
  • In Search Console, compare “Discovered URLs” per sitemap with indexed counts in the Page indexing report filtered by that sitemap.

Crawl budget (large sites only)

Crawl budget matters for roughly 1M+ URLs or sites whose URLs change daily at scale. Signs: Crawl stats show many requests to parameter URLs, important new pages take weeks to be crawled, “Discovered - currently not indexed” grows. Fixes in order:

  1. Stop generating crawlable infinite spaces (calendars, faceted combos, session IDs).
  2. Disallow the pattern in robots.txt once it has no indexed value.
  3. Return real 404/410 for removed content instead of soft 404s.
  4. Keep server response fast; Googlebot slows down when the host slows or errors.
  5. Keep sitemaps limited to URLs you want indexed and lastmod accurate.

Gotchas

  • A CDN or framework may serve its own robots.txt that overrides the file in the repo. Always validate the production URL, not only the source file.
  • Disallow: with an empty value allows everything; Disallow: / blocks everything. A staging robots.txt promoted to production is a classic outage.
  • Robots rules are matched against the URL-encoded path; non-ASCII paths must be percent-encoded in the file.
  • Robots.txt changes are cached by Google for up to about 24 hours.
  • Search Console “Indexed, though blocked by robots.txt”: decide whether the URL should be indexed. If not, unblock it and serve noindex; if yes, unblock it.

Output

When delivering changes, report:

**robots.txt:** added Disallow for /*?*sort= (12k crawled-not-indexed URLs); no rendering
paths blocked (tested /_next/static/*, /api/products with seo_files.py).
**Sitemaps:** index + 4 children, 182,340 URLs, all canonical; lastmod from updated_at.
**Validation:** seo_files.py sitemap exit 0 on all files; 50-URL sample all 200.
**Next:** submit sitemap_index.xml in Search Console; recheck coverage in 14 days.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Sitemaps and Robots.txt"
  short_description: "Crawl rules Google actually reads the way you meant"
  default_prompt: "$sitemaps-robots Generate a sitemap index for our 300k product pages from the products table"
scripts/seo_files.py267 lines, 10.0 KB
#!/usr/bin/env python3
"""Validate XML sitemaps and test URLs against robots.txt with Google's matching rules.

Standard library only (Python 3.10+).

  python3 scripts/seo_files.py sitemap <file|url> [--host www.example.com] [--robots robots.txt]
  python3 scripts/seo_files.py robots <robots.txt file|url> <url-or-path> [...] [--agent Googlebot]

Python's urllib.robotparser uses first-match prefix rules and ignores * and $, so it
disagrees with Google on many real files. This script implements the RFC 9309 / Google
behaviour: most specific user-agent group, * and $ wildcards, longest match wins,
allow wins ties.

Exit codes: 0 ok, 1 problems found (errors, or a tested URL is disallowed), 2 bad input.
"""
from __future__ import annotations

import argparse
import gzip
import re
import sys
import urllib.parse
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from xml.etree import ElementTree as ET

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
MAX_URLS = 50_000
MAX_BYTES = 50 * 1024 * 1024
W3C_DATE = re.compile(
    r"^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$"
)


def load(source: str) -> bytes:
    if re.match(r"^https?://", source):
        req = urllib.request.Request(source, headers={"User-Agent": "seo-files-check/1.0"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = resp.read()
    else:
        with open(source, "rb") as fh:
            data = fh.read()
    if data[:2] == b"\x1f\x8b":
        data = gzip.decompress(data)
    return data


# ---------------------------------------------------------------- robots.txt

def parse_robots(text: str) -> tuple[dict[str, list[tuple[str, str]]], list[str]]:
    groups: dict[str, list[tuple[str, str]]] = {}
    sitemaps: list[str] = []
    current: list[str] = []
    last_was_agent = False
    for raw in text.splitlines():
        line = raw.split("#", 1)[0].strip()
        if ":" not in line:
            continue
        key, value = (p.strip() for p in line.split(":", 1))
        key = key.lower()
        if key == "user-agent":
            if not last_was_agent:
                current = []
            agent = value.lower()
            current.append(agent)
            groups.setdefault(agent, [])
            last_was_agent = True
        elif key in ("allow", "disallow"):
            last_was_agent = False
            for agent in current:
                groups[agent].append((key, value))
        elif key == "sitemap":
            sitemaps.append(value)
        else:
            last_was_agent = False
    return groups, sitemaps


def pick_group(groups: dict, agent: str) -> tuple[str, list[tuple[str, str]]]:
    agent = agent.lower()
    if agent in groups:
        return agent, groups[agent]
    prefixes = [g for g in groups if g != "*" and agent.startswith(g)]
    if prefixes:
        best = max(prefixes, key=len)
        return best, groups[best]
    return "*", groups.get("*", [])


def rule_regex(path: str) -> re.Pattern:
    anchored = path.endswith("$")
    body = path[:-1] if anchored else path
    pattern = "".join(".*" if ch == "*" else re.escape(ch) for ch in body)
    return re.compile("^" + pattern + ("$" if anchored else ""))


def is_allowed(rules: list[tuple[str, str]], url_path: str) -> tuple[bool, str]:
    best: tuple[int, int, str] | None = None  # (length, allow_flag, rule)
    for kind, path in rules:
        if not path:
            continue  # empty Disallow allows everything; empty Allow is a no-op
        if rule_regex(path).match(url_path):
            cand = (len(path), 1 if kind == "allow" else 0, f"{kind.title()}: {path}")
            if best is None or cand[:2] > best[:2]:
                best = cand
    if best is None:
        return True, "no matching rule"
    return bool(best[1]), best[2]


def path_of(url: str) -> str:
    parts = urllib.parse.urlsplit(url)
    path = parts.path or "/"
    return path + ("?" + parts.query if parts.query else "")


def cmd_robots(args) -> int:
    data = load(args.robots)
    if len(data) > 500 * 1024:
        print(f"WARN robots.txt is {len(data)} bytes; Google ignores content after 500 KiB")
    groups, sitemaps = parse_robots(data.decode("utf-8", errors="replace"))
    name, rules = pick_group(groups, args.agent)
    print(f"Agent '{args.agent}' obeys group: user-agent: {name} ({len(rules)} rules)")
    for s in sitemaps:
        print(f"Sitemap line: {s}")
    blocked = 0
    for target in args.urls:
        ok, why = is_allowed(rules, path_of(target) if "://" in target else target)
        blocked += not ok
        print(f"{'ALLOW ' if ok else 'BLOCK '} {target}  ({why})")
    return 1 if blocked else 0


# ---------------------------------------------------------------- sitemaps

def cmd_sitemap(args) -> int:
    errors: list[str] = []
    warns: list[str] = []
    try:
        data = load(args.sitemap)
    except Exception as exc:
        print(f"ERROR cannot load {args.sitemap}: {exc}")
        return 2
    if len(data) > MAX_BYTES:
        errors.append(f"uncompressed size {len(data)} bytes exceeds 50 MB")
    if data.lstrip()[:5].lower() == b"<html" or b"<!doctype html" in data[:200].lower():
        print("ERROR the sitemap URL returned HTML, not XML")
        return 1
    try:
        root = ET.fromstring(data)
    except ET.ParseError as exc:
        print(f"ERROR invalid XML: {exc} (check unescaped & in URLs)")
        return 1

    kind = root.tag.replace(NS, "")
    if not root.tag.startswith(NS):
        errors.append(f"root <{root.tag}> is not in the sitemaps.org 0.9 namespace")
    child_tag = "url" if kind == "urlset" else "sitemap" if kind == "sitemapindex" else None
    if child_tag is None:
        print(f"ERROR root element must be urlset or sitemapindex, got {kind}")
        return 1

    entries = root.findall(f"{NS}{child_tag}")
    if len(entries) > MAX_URLS:
        errors.append(f"{len(entries)} entries exceeds the 50,000 limit; split into a sitemap index")

    rules = None
    if args.robots:
        groups, _ = parse_robots(load(args.robots).decode("utf-8", errors="replace"))
        rules = pick_group(groups, "googlebot")[1]

    locs: list[str] = []
    lastmods: list[str] = []
    future = 0
    ignored_tags = Counter()
    now = datetime.now(timezone.utc)
    for entry in entries:
        loc_el = entry.find(f"{NS}loc")
        loc = (loc_el.text or "").strip() if loc_el is not None else ""
        if not loc:
            errors.append(f"<{child_tag}> without <loc>")
            continue
        locs.append(loc)
        parts = urllib.parse.urlsplit(loc)
        if parts.scheme not in ("http", "https") or not parts.netloc:
            errors.append(f"relative or invalid loc: {loc}")
        elif args.host and parts.netloc.lower() != args.host.lower():
            errors.append(f"loc on another host ({parts.netloc}): {loc}")
        if parts.fragment:
            warns.append(f"loc contains a #fragment: {loc}")
        if rules is not None:
            ok, why = is_allowed(rules, path_of(loc))
            if not ok:
                errors.append(f"listed but blocked by robots.txt ({why}): {loc}")
        lm_el = entry.find(f"{NS}lastmod")
        if lm_el is not None and lm_el.text:
            lm = lm_el.text.strip()
            lastmods.append(lm)
            if not W3C_DATE.match(lm):
                errors.append(f"lastmod not W3C Datetime ({lm}): {loc}")
            else:
                try:
                    stamp = datetime.fromisoformat(lm.replace("Z", "+00:00"))
                    if stamp.tzinfo is None:
                        stamp = stamp.replace(tzinfo=timezone.utc)
                    if stamp > now:
                        future += 1
                except ValueError:
                    pass
        for tag in ("priority", "changefreq"):
            if entry.find(f"{NS}{tag}") is not None:
                ignored_tags[tag] += 1

    dupes = [u for u, n in Counter(locs).items() if n > 1]
    if dupes:
        warns.append(f"{len(dupes)} duplicate loc values, e.g. {dupes[0]}")
    if future:
        errors.append(f"{future} lastmod values are in the future")
    if lastmods and len(lastmods) > 20 and len(set(lastmods)) == 1:
        warns.append("every lastmod is identical; Google ignores lastmod it cannot trust")
    if entries and not lastmods:
        warns.append("no lastmod values; add accurate ones for changed pages")
    for tag, n in ignored_tags.items():
        warns.append(f"{n} entries use <{tag}>, which Google ignores (harmless)")
    if kind == "sitemapindex":
        nested = [u for u in locs if "sitemap" in u.lower() and u.lower().endswith("index.xml")]
        if nested:
            warns.append("index appears to reference another index; Google does not support nesting")

    print(f"{kind}: {len(entries)} entries, {len(data):,} bytes uncompressed")
    for e in errors[:50]:
        print(f"ERROR {e}")
    if len(errors) > 50:
        print(f"ERROR ... {len(errors) - 50} more")
    for w in warns:
        print(f"WARN  {w}")
    if kind == "sitemapindex":
        print("Child sitemaps (validate each one separately):")
        for u in locs:
            print(f"  {u}")
    if not errors:
        print("OK")
    return 1 if errors else 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    s = sub.add_parser("sitemap", help="validate a sitemap or sitemap index (.xml or .xml.gz)")
    s.add_argument("sitemap")
    s.add_argument("--host", help="expected host for every loc, e.g. www.example.com")
    s.add_argument("--robots", help="robots.txt file or URL to cross-check each loc against Googlebot")
    r = sub.add_parser("robots", help="test URLs or paths against robots.txt")
    r.add_argument("robots")
    r.add_argument("urls", nargs="+")
    r.add_argument("--agent", default="Googlebot")
    args = ap.parse_args()
    try:
        return cmd_sitemap(args) if args.cmd == "sitemap" else cmd_robots(args)
    except (OSError, ValueError) as exc:
        print(f"ERROR {exc}", file=sys.stderr)
        return 2


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

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc