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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- sitemaps-robots Installs to ~/.agents/skills/sitemaps-robots/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install sitemaps-robots The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project sitemaps-robots Installs to .agents/skills/sitemaps-robots/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/sitemaps-robots.zipunzip -o sitemaps-robots.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) sitemaps-robots PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
| File | Controls | Does not control |
|---|---|---|
| robots.txt | Crawling (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) | Indexing | Crawling. The bot must be allowed to fetch the page to see it |
| XML sitemap | Discovery and recrawl hints | Inclusion. 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.jsapp/sitemap.ts/app/robots.ts(seenextjs-seo),next-sitemap,@astrojs/sitemap, WordPress core or SEO plugins, static files inpublic/, CDN workers that rewrite/robots.txt. - Fetch production versions (network needed):
curl -s https://www.example.com/robots.txtand everySitemap:URL it lists. Check robots.txt returns 200 withtext/plain.
2. Decide per URL pattern
| Pattern | Crawl | Index | In sitemap |
|---|---|---|---|
| Canonical content pages | allow | yes | yes |
Internal search results /search?q= | disallow | no | no |
| Filter/sort permutations with no search demand | disallow pattern (or canonicalize if few) | no | no |
Filter combos with real demand (e.g. /shoes/red) | allow | yes | yes, as clean URLs |
| Cart, checkout, account, admin | disallow | no | no |
| Pages being removed from the index | allow + noindex until gone | no | no |
| JS, CSS, images, API endpoints needed for rendering | allow | n/a | no |
| Staging/preview hosts | auth or noindex header; not robots.txt alone | no | no |
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-agentgroup that matches it, and ignores*rules entirely if a named group exists. If you addUser-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,
Allowwins. Crawl-delayis 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,
noindexpages, parameters you canonicalize away, or blocked URLs. lastmodin W3C Datetime (2026-10-28or2026-10-28T09:30:00+00:00), changed only when the main content changes. Setting everylastmodto the build time teaches Google to ignore it. Google ignores<priority>and<changefreq>; omit them.- Escape
&as&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:
- Stop generating crawlable infinite spaces (calendars, faceted combos, session IDs).
- Disallow the pattern in robots.txt once it has no indexed value.
- Return real 404/410 for removed content instead of soft 404s.
- Keep server response fast; Googlebot slows down when the host slows or errors.
- Keep sitemaps limited to URLs you want indexed and
lastmodaccurate.
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.yaml
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.py
#!/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.