Most SEO audit tools give you a dashboard full of numbers and leave the thinking to you. You scroll through hundreds of flagged issues, ignore half of them, and forget the rest until next month.
What if the audit tool could reason about your site — figure out which issues actually matter, suggest fixes, and write a report you can act on immediately?
That’s what we’re building here: an autonomous SEO audit agent using Claude Code that runs on a schedule, crawls your site, checks the things that matter for ranking, and emails you a prioritized action list every week.
No paid SEO tools. No dashboards. Just a Python script, Claude Code, and your own judgment embedded into the agent’s instructions.
What This Agent Actually Does
Every Sunday morning, the agent will:
- Crawl your site — follow internal links from your homepage and collect every URL
- Audit each page — check title tags, meta descriptions, H1s, word count, and image alt text
- Flag real problems — not every missing alt tag, just the ones on pages that actually matter
- Write a report — a markdown file with prioritized fixes, organized by impact
- Email it to you — so it’s waiting in your inbox on Monday morning
By the end of this tutorial you’ll have a working agent that runs without you touching it.
Prerequisites
Before starting, you’ll need:
- Claude Code installed (
npm install -g @anthropic-ai/claude-code) - Python 3.9+ with
pip - A site with a public URL (works on GitHub Pages, Vercel, Netlify — anything publicly accessible)
- A Gmail account for sending the weekly report (or swap in any SMTP provider)
Install the Python dependencies we’ll use:
pip install requests beautifulsoup4 python-dotenv
Project Structure
Create a folder for the agent:
seo-agent/
├── CLAUDE.md # Agent instructions and decision rules
├── crawl.py # Crawls the site and collects URLs + page data
├── audit.py # Runs checks on each page
├── report.py # Generates the markdown report
├── notify.py # Emails the report
├── .env # Your credentials (never commit this)
└── .claude/
└── settings.json # Schedules the weekly run
Step 1: Define the Agent’s Persona in CLAUDE.md
This is the most important file. It tells Claude Code how to think when it runs the audit — what to prioritize, what to ignore, and how to format its output.
Create CLAUDE.md:
# SEO Audit Agent
You are an SEO audit agent for a content blog. Your job is to crawl the site,
identify issues that are likely to hurt search rankings, and produce a prioritized
report with clear, actionable fixes.
## Core Responsibilities
1. Run crawl.py to collect all pages and their metadata
2. Run audit.py to check each page against the rules below
3. Generate a report using report.py
4. Send the report using notify.py
## Audit Rules
### Critical (fix this week)
- Pages with no title tag
- Pages with duplicate title tags
- Pages with no meta description
- Pages with word count under 300
### Important (fix this month)
- Title tags over 60 characters
- Meta descriptions over 160 characters
- Missing H1 on content pages
- Images with no alt text on pages with 500+ words
### Low priority (fix when convenient)
- Duplicate meta descriptions
- Pages with only one internal link
- Images with generic alt text like "image" or "photo"
## Report Format
Write the report as a markdown file named `audit-YYYY-MM-DD.md`.
Use this structure:
1. Summary (3 sentences max — what's the overall health of the site?)
2. Critical Issues (with page URL and exact fix)
3. Important Issues (grouped by type, not by page)
4. Low Priority Issues (bulleted list only, no explanations needed)
5. One thing that looks good (genuinely positive observation)
Be direct. Skip the preamble. The person reading this has 10 minutes on a Monday morning.
Step 2: The Crawler
The crawler visits your homepage, finds every internal link, follows them, and collects the raw HTML and metadata for each page.
Create crawl.py:
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
import json
import sys
def crawl_site(start_url: str, max_pages: int = 100) -> list[dict]:
"""Crawl a site starting from start_url. Returns list of page data dicts."""
visited = set()
to_visit = [start_url]
pages = []
domain = urlparse(start_url).netloc
while to_visit and len(visited) < max_pages:
url = to_visit.pop(0)
if url in visited:
continue
try:
response = requests.get(url, timeout=10, headers={
"User-Agent": "SEO-Audit-Agent/1.0 (personal site audit)"
})
response.raise_for_status()
except requests.RequestException as e:
print(f"Failed to fetch {url}: {e}", file=sys.stderr)
visited.add(url)
continue
visited.add(url)
soup = BeautifulSoup(response.text, "html.parser")
# Collect page metadata
title = soup.find("title")
description = soup.find("meta", attrs={"name": "description"})
h1_tags = soup.find_all("h1")
images = soup.find_all("img")
word_count = len(soup.get_text().split())
pages.append({
"url": url,
"title": title.get_text(strip=True) if title else None,
"description": description.get("content") if description else None,
"h1_count": len(h1_tags),
"h1_text": [h.get_text(strip=True) for h in h1_tags],
"word_count": word_count,
"image_count": len(images),
"images_missing_alt": [
img.get("src", "") for img in images
if not img.get("alt") or img.get("alt", "").strip() == ""
],
"internal_links": [],
})
# Find internal links to follow
for link in soup.find_all("a", href=True):
href = urljoin(url, link["href"])
parsed = urlparse(href)
if parsed.netloc == domain and href not in visited:
to_visit.append(href)
pages[-1]["internal_links"].append(href)
return pages
if __name__ == "__main__":
site_url = sys.argv[1] if len(sys.argv) > 1 else "https://theworkatlas.com"
results = crawl_site(site_url)
print(json.dumps(results, indent=2))
Run it manually to test:
python crawl.py https://yoursite.com
You should see a JSON array with one entry per page. If you see errors, check that the site is publicly accessible.
Step 3: The Auditor
The auditor takes the crawl output and applies the rules from CLAUDE.md. It returns a structured list of issues, not a narrative — Claude Code will write the narrative.
Create audit.py:
import json
import sys
from dataclasses import dataclass, asdict
@dataclass
class Issue:
severity: str # "critical", "important", "low"
page_url: str
issue_type: str
detail: str
suggested_fix: str
def audit_pages(pages: list[dict]) -> list[dict]:
issues = []
titles_seen = {}
for page in pages:
url = page["url"]
title = page.get("title")
description = page.get("description")
word_count = page.get("word_count", 0)
h1_count = page.get("h1_count", 0)
# --- Critical checks ---
if not title:
issues.append(Issue(
severity="critical",
page_url=url,
issue_type="missing_title",
detail="Page has no <title> tag.",
suggested_fix="Add a descriptive title tag under 60 characters."
))
elif title in titles_seen:
issues.append(Issue(
severity="critical",
page_url=url,
issue_type="duplicate_title",
detail=f"Same title as {titles_seen[title]}: '{title}'",
suggested_fix="Write a unique title that reflects this page's specific content."
))
else:
titles_seen[title] = url
if not description:
issues.append(Issue(
severity="critical",
page_url=url,
issue_type="missing_meta_description",
detail="Page has no meta description.",
suggested_fix="Add a meta description between 120–160 characters."
))
if word_count < 300:
issues.append(Issue(
severity="critical",
page_url=url,
issue_type="thin_content",
detail=f"Only {word_count} words on this page.",
suggested_fix="Expand the content or consider removing/redirecting this page."
))
# --- Important checks ---
if title and len(title) > 60:
issues.append(Issue(
severity="important",
page_url=url,
issue_type="title_too_long",
detail=f"Title is {len(title)} characters: '{title}'",
suggested_fix="Shorten to under 60 characters. Keep the primary keyword near the front."
))
if description and len(description) > 160:
issues.append(Issue(
severity="important",
page_url=url,
issue_type="meta_description_too_long",
detail=f"Meta description is {len(description)} characters.",
suggested_fix="Trim to 160 characters. Prioritize the clearest benefit statement."
))
if h1_count == 0 and word_count > 200:
issues.append(Issue(
severity="important",
page_url=url,
issue_type="missing_h1",
detail="Content page has no H1 heading.",
suggested_fix="Add one H1 that matches or closely reflects the title tag."
))
if h1_count > 1:
issues.append(Issue(
severity="important",
page_url=url,
issue_type="multiple_h1",
detail=f"Page has {h1_count} H1 tags.",
suggested_fix="Use exactly one H1. Demote others to H2 or H3."
))
if word_count >= 500 and page.get("images_missing_alt"):
for img_src in page["images_missing_alt"]:
issues.append(Issue(
severity="important",
page_url=url,
issue_type="image_missing_alt",
detail=f"Image missing alt text: {img_src}",
suggested_fix="Add descriptive alt text that explains what the image shows."
))
# --- Low priority checks ---
if len(page.get("internal_links", [])) <= 1 and word_count > 300:
issues.append(Issue(
severity="low",
page_url=url,
issue_type="few_internal_links",
detail="Page has 1 or fewer internal links.",
suggested_fix="Add 2–4 links to related content on your site."
))
return [asdict(i) for i in issues]
if __name__ == "__main__":
pages = json.loads(sys.stdin.read())
issues = audit_pages(pages)
print(json.dumps(issues, indent=2))
Test the pipeline end-to-end:
python crawl.py https://yoursite.com | python audit.py
Step 4: The Report Writer
This script takes the issues JSON and formats it into a readable markdown report. Claude Code will fill in the summary and tone — the script just handles the structure.
Create report.py:
import json
import sys
from datetime import date
from collections import defaultdict
def generate_report(pages: list[dict], issues: list[dict]) -> str:
today = date.today().isoformat()
critical = [i for i in issues if i["severity"] == "critical"]
important = [i for i in issues if i["severity"] == "important"]
low = [i for i in issues if i["severity"] == "low"]
lines = [
f"# SEO Audit Report — {today}",
"",
f"**Pages crawled:** {len(pages)} ",
f"**Total issues found:** {len(issues)} ",
f"**Critical:** {len(critical)} | **Important:** {len(important)} | **Low:** {len(low)}",
"",
"---",
"",
"## Critical Issues (Fix This Week)",
"",
]
if critical:
for issue in critical:
lines += [
f"### {issue['issue_type'].replace('_', ' ').title()}",
f"**Page:** {issue['page_url']} ",
f"**Problem:** {issue['detail']} ",
f"**Fix:** {issue['suggested_fix']}",
"",
]
else:
lines.append("No critical issues found. Nice work.\n")
lines += ["---", "", "## Important Issues (Fix This Month)", ""]
if important:
by_type = defaultdict(list)
for issue in important:
by_type[issue["issue_type"]].append(issue)
for issue_type, group in by_type.items():
lines.append(f"### {issue_type.replace('_', ' ').title()} ({len(group)} pages)")
for issue in group:
lines.append(f"- `{issue['page_url']}` — {issue['detail']}")
lines.append(f"\n**Fix:** {group[0]['suggested_fix']}\n")
else:
lines.append("No important issues found.\n")
lines += ["---", "", "## Low Priority", ""]
for issue in low:
lines.append(f"- `{issue['page_url']}`: {issue['detail']}")
# Save report
filename = f"audit-{today}.md"
with open(filename, "w") as f:
f.write("\n".join(lines))
print(f"Report saved to {filename}")
return filename
if __name__ == "__main__":
data = json.loads(sys.stdin.read())
pages = data["pages"]
issues = data["issues"]
generate_report(pages, issues)
Step 5: Email Notification
This sends the report to your inbox every week. Uses Gmail’s SMTP — you’ll need to generate an App Password since regular Gmail passwords don’t work with SMTP.
Create notify.py:
import smtplib
import sys
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from dotenv import load_dotenv
load_dotenv()
def send_report(report_path: str) -> None:
sender = os.getenv("GMAIL_ADDRESS")
password = os.getenv("GMAIL_APP_PASSWORD")
recipient = os.getenv("REPORT_RECIPIENT", sender)
if not sender or not password:
print("Missing GMAIL_ADDRESS or GMAIL_APP_PASSWORD in .env", file=sys.stderr)
sys.exit(1)
with open(report_path) as f:
body = f.read()
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Weekly SEO Audit — {report_path}"
msg["From"] = sender
msg["To"] = recipient
msg.attach(MIMEText(body, "plain"))
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender, password)
server.sendmail(sender, recipient, msg.as_string())
print(f"Report sent to {recipient}")
if __name__ == "__main__":
report_path = sys.argv[1]
send_report(report_path)
Create .env (never commit this to git):
GMAIL_ADDRESS=you@gmail.com
GMAIL_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
REPORT_RECIPIENT=you@gmail.com
SITE_URL=https://yoursite.com
Step 6: Schedule the Weekly Run
Create .claude/settings.json to tell Claude Code when to run the agent:
{
"hooks": {
"schedule": [
{
"name": "Weekly SEO Audit",
"cron": "0 8 * * 0",
"prompt": "Run the SEO audit for $SITE_URL. Use crawl.py to collect page data, pipe the output to audit.py, then combine both outputs and pipe to report.py. Finally run notify.py with the path to the generated report file. If any script fails, log the error and stop — don't send a partial report."
}
]
}
}
The cron 0 8 * * 0 runs at 8:00 AM every Sunday. Change it to whatever day works for your workflow.
Step 7: Risk Controls
Three things to lock in before you let this run unattended:
1. Never commit credentials. Add .env to your .gitignore immediately:
echo ".env" >> .gitignore
echo "audit-*.md" >> .gitignore
2. Cap the crawl. The max_pages=100 limit in crawl.py prevents the agent from getting stuck on a site with thousands of pages. Adjust based on your site size — most content blogs don’t need more than 200.
3. Test with --dry-run first. Before enabling the schedule, run the full pipeline manually and check that the report looks right:
python crawl.py https://yoursite.com > pages.json
cat pages.json | python audit.py > issues.json
echo '{}' | python -c "
import json, sys
pages = json.load(open('pages.json'))
issues = json.load(open('issues.json'))
print(json.dumps({'pages': pages, 'issues': issues}))
" | python report.py
Open the generated audit-YYYY-MM-DD.md file and check that the issues make sense before wiring up the email step.
Step 8: Running the Full Agent
Once everything is in place, run the agent manually for the first time:
claude "Run the SEO audit for $SITE_URL. Use crawl.py to collect page data, pipe the output to audit.py, combine both outputs and pipe to report.py, then run notify.py with the report filename."
Claude Code will:
- Execute
crawl.pyagainst your site - Pipe the output through
audit.py - Read both outputs, reason about the results, and generate the report
- Send you the email
The first run usually takes 2–5 minutes depending on your site size. After that, it runs every Sunday morning without you touching it.
What a Real Report Looks Like
Here’s a sample of what the agent produces for a 20-page content blog:
# SEO Audit Report — 2026-06-15
**Pages crawled:** 21
**Total issues found:** 9
**Critical:** 2 | **Important:** 5 | **Low:** 2
---
## Critical Issues (Fix This Week)
### Thin Content
**Page:** https://yoursite.com/posts/quick-review
**Problem:** Only 187 words on this page.
**Fix:** Expand the content or consider removing/redirecting this page.
### Missing Meta Description
**Page:** https://yoursite.com/posts/tool-roundup
**Problem:** Page has no meta description.
**Fix:** Add a meta description between 120–160 characters.
Short, direct, actionable. No filler.
Realistic Expectations
This agent won’t fix your SEO. It finds the issues — you still have to fix them.
What it does do is make sure those issues don’t pile up invisibly for months. A blog that catches and fixes two or three SEO problems per week compounding over a year looks dramatically different in search results than one that doesn’t.
The HN effect (one great post catching 40k views) matters. But it compounds with a technically clean site that Google can crawl, index, and understand. This agent handles the second part.
Extending the Agent
Once the basic audit is running, a few extensions worth adding:
Track issues over time. Save each week’s issues.json with a date prefix. After a month you can run a diff to see which issues were fixed and which are recurring.
Add a broken link checker. Extend audit.py with a function that fetches each internal link and checks for 404 responses.
Check Core Web Vitals. The PageSpeed Insights API is free and returns LCP, CLS, and FID scores per URL. Worth adding as an “important” check.
Slack notification instead of email. Swap notify.py for a Slack webhook if your team uses it.
FAQ
Does this work on any site, or just static sites? Any publicly accessible site. The crawler uses HTTP requests, not JavaScript rendering, so it works best on sites where content is in the HTML (static sites, server-rendered sites). Single-page apps built with React or Vue may need a headless browser — a future extension worth adding.
How much does it cost to run? Almost nothing. The Claude Code API calls for a 20-page audit typically use under 5,000 tokens. At current pricing that’s less than a cent per run. Monthly cost is well under $1 for a typical content blog.
What if the crawl misses pages?
If your site has pages that aren’t linked from other pages (orphaned content), the crawler won’t find them. Add those URLs manually to a urls.txt file and extend crawl.py to read from it as a starting set.
Can I run this on a client’s site?
Yes, but check their robots.txt first. The agent respects max_pages but doesn’t currently parse robots.txt. Add robotparser from Python’s standard library if you need to be more careful.
What if an email doesn’t arrive?
Check your spam folder first. If it’s not there, run notify.py manually with the report path and look at the error output. The most common issue is an incorrect App Password.
Key Takeaways
- You don’t need a paid SEO tool for a content blog under a few hundred pages. A Python script and Claude Code cover 80% of what matters.
- Weekly cadence beats monthly. Small issues caught early are 10-minute fixes. The same issues ignored for 3 months become rewrites.
- The agent reasons, not just scans. CLAUDE.md embeds your priorities — what counts as critical vs. low priority — so the output matches how you think about your site, not how an off-the-shelf tool does.
- Start with the report, not the schedule. Run it manually a few times before trusting the Sunday automation.
- Thin content is the highest-leverage fix for most new content blogs. The agent will find it every time.