How to Run a PageSpeed SEO Audit From Claude Code Without Unreviewed Site Changes

Create a project-local Claude Code workflow that turns PageSpeed evidence into an approved, testable website change.

How to Run a PageSpeed SEO Audit From Claude Code Without Unreviewed Site Changes

Claude Code is most useful after a PageSpeed result points toward a likely implementation area. It can inspect a repository, connect repeated audit findings to templates or asset pipelines, and prepare a testable patch. That power needs a gate: collecting evidence is read-only; changing a website begins only after a human approves a narrowed plan.

This tutorial gives a repository a local skill, an evidence directory, and a short policy. The result is not an auto-optimize bot. It is a repeatable route from PageSpeed Insights evidence to an approved, reviewable diff.

Send this article to Claude Code to install the skill

Once this article has a live URL, paste this request into Claude Code. It installs the workflow without testing a site or altering application code.

Read [THIS ARTICLE URL] and install its project-local PageSpeed audit workflow.

First inspect this repository for CLAUDE.md, CLAUDE.local.md, .claude rules,
and project conventions. Explain where these files will go before writing them:
.claude/skills/pagespeed-evidence/SKILL.md
.claude/skills/pagespeed-evidence/scripts/pagespeed_evidence.py
.claude/rules/page-speed-audits.md

Create each file from the complete code blocks in the article. Do not change
application code, package files, lock files, CI, infrastructure, or deployment
configuration. Do not call PageSpeed Insights or inspect public URLs.

Run a Python syntax check on the runner. Report the paths, confirm audit output
is ignored by Git, and tell me to configure PAGESPEED_API_KEY in my approved
local secret environment without revealing or requesting the value. Stop there.

Claude Code uses CLAUDE.md and .claude files for persistent project guidance. They are useful context, not a security boundary by themselves. If a team must block a class of tool actions regardless of agent judgment, use Claude Code permissions or PreToolUse hooks according to the current official documentation. The workflow below keeps the audit command read-only and asks for separate approval before edits.

Completion contract

Item

Completion condition

Reader

A developer, SEO lead, or technical content owner working in a website repository

Outcome

A project-local /pagespeed-evidence skill, an ignored evidence folder, and an approval-ready implementation brief

Inputs

One public URL, a URL file, or a controlled sitemap sample

Prerequisites

Claude Code, Python 3.9+, PageSpeed Insights API access, and a Git repository

Time

About 35 minutes for installation and a baseline; longer only after the code owner approves a fix

Done means

Raw responses and report.md exist, the repository is unchanged by the audit, and any proposed fix names tests and rollback conditions

The distinction is simple. reports/pagespeed/ contains API evidence. A repair plan connects that evidence to code candidates. A Git diff is implementation work. Keep those states visible rather than allowing them to blur into one agent request.

Prepare the repository boundary before the first audit

Create an ignored evidence location. This lets Claude Code inspect audit output without treating it as product source code or accidentally committing API responses.

mkdir -p reports/pagespeed
printf 'reports/pagespeed/
' >> .gitignore

Expected output: git status --short shows only the .gitignore change. Quality check: run git check-ignore -v reports/pagespeed/example/report.md after creating a temporary path; it should point to the new ignore rule. Recovery: if the repository has a dedicated generated-artifacts rule, use that approved location instead and change the skill policy to match. Do not place reports in src/, a deployment directory, or a tracked docs/ folder by default.

Save this rule as .claude/rules/page-speed-audits.md. If the repository does not use .claude/rules, place the same text in its existing CLAUDE.md policy section.

# PageSpeed audit policy

- PageSpeed work starts as read-only evidence collection. Write audit output only under reports/pagespeed/, which must stay ignored by Git.
- Read PAGESPEED_API_KEY only from an approved local environment or secret mechanism. Never print it, add it to a command transcript, write it to a report, or commit it.
- Do not edit application source, content, build files, CI, infrastructure, deployment configuration, or a CMS while collecting or interpreting an audit.
- After an audit, create an implementation brief that names evidence, candidate files, risk, tests, acceptance criteria, and rollback condition. Wait for explicit approval before making a diff.
- Never deploy. After an approved implementation, show the Git diff and run only agreed local validation commands.

This policy tells Claude Code how to work in the project; it does not override an organization-wide permissions policy. For a sensitive repository, enforce the same boundary with your team-approved permission and hook controls.

Repository map showing a Claude Code skill and audit rules writing PageSpeed results to an ignored evidence directory while application source remains protected.

The audit creates evidence inside the repository without turning that evidence into product source or a commit.

Install an evidence skill, not a repair bot

Create .claude/skills/pagespeed-evidence/SKILL.md:

---
name: pagespeed-evidence
description: Collect PageSpeed Insights evidence for one public URL, a supplied URL list, or a controlled XML sitemap sample. Save raw JSON and a Markdown report under the project's ignored reports/pagespeed directory. Use for page speed, Core Web Vitals, Lighthouse, and performance SEO investigation. Audit work is read-only: do not edit source, content, configuration, or deployments until the user explicitly approves an implementation brief.
---

# PageSpeed Evidence for This Repository

## Guardrails

- Before running, read .claude/rules/page-speed-audits.md or the equivalent project policy. If it conflicts with this skill, follow the stricter rule.
- Read PAGESPEED_API_KEY only from the local environment. Never expose it.
- Make GET requests only to the public PageSpeed Insights endpoint and public sitemap URLs. Write only beneath reports/pagespeed/.
- Test mobile and desktop. Preserve each raw response. Lighthouse is point-in-time lab data; loadingExperience and originLoadingExperience are CrUX field data only when returned, and have different scopes.
- A sitemap sample is not a crawl. State the sample cap and selected URLs. For release-critical pages, use a curated URL file.

## Collect a baseline

python3 .claude/skills/pagespeed-evidence/scripts/pagespeed_evidence.py \
--url "https://www.example.com/pricing/" \
--out reports/pagespeed/pricing-baseline

For a sitemap sample:

python3 .claude/skills/pagespeed-evidence/scripts/pagespeed_evidence.py \
--sitemap "https://www.example.com/sitemap.xml" \
--max-urls 12 \
--out reports/pagespeed/site-sample

## Interpret before planning

First report scope, final URLs, request failures, and whether field data exists. Then identify repeated opportunities by page template or mechanism. A score alone is not a root cause and does not predict rankings.

## Handoff to an approved implementation task

Do not edit files after reporting. Create a brief with evidence path, affected URLs, laboratory or field-data scope, candidate files and why they are candidates, proposed smallest change, owner, local test, acceptance criteria, and rollback condition. Ask for approval. Once approved, inspect only the named code path, make the smallest diff, show git diff, run agreed tests, and do not deploy.

Then save this standard-library runner as .claude/skills/pagespeed-evidence/scripts/pagespeed_evidence.py:

#!/usr/bin/env python3
"""Write PageSpeed audit evidence to an ignored repository directory."""
from __future__ import annotations

import argparse
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import OrderedDict
from datetime import datetime, timezone
from pathlib import Path

API_URL = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
STRATEGIES = ("mobile", "desktop")
CATEGORIES = ("performance", "accessibility", "best-practices", "seo")
AUDITS = ("largest-contentful-paint", "interaction-to-next-paint", "cumulative-layout-shift", "total-blocking-time")


def fetch(url: str, timeout: int = 45) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "ClaudeCode-PageSpeed-Evidence/1.0"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()


def sitemap(url: str) -> list[str]:
try:
root = ET.fromstring(fetch(url))
except (urllib.error.URLError, ET.ParseError) as error:
raise RuntimeError(f"Cannot parse sitemap {url}: {error}") from error
locations = [node.text.strip() for node in root.findall(".//{*}loc") if node.text and node.text.strip()]
if root.tag.lower().endswith("sitemapindex"):
locations = [nested for location in locations for nested in sitemap(location)]
return list(OrderedDict((url, None) for url in locations if urllib.parse.urlparse(url).scheme in {"http", "https"}))


def sample(urls: list[str], maximum: int) -> list[str]:
groups: OrderedDict[str, str] = OrderedDict()
for url in urls:
segment = next((part for part in urllib.parse.urlparse(url).path.split("/") if part), "root")
groups.setdefault(segment, url)
chosen = list(groups.values())
chosen.extend(url for url in urls if url not in chosen)
return chosen[:maximum]


def request_result(url: str, strategy: str, key: str) -> dict:
parameters = [("url", url), ("strategy", strategy), ("key", key)]
parameters.extend(("category", category) for category in CATEGORIES)
endpoint = API_URL + "?" + urllib.parse.urlencode(parameters)
failure = "unknown error"
for attempt in range(3):
try:
return json.loads(fetch(endpoint, timeout=150))
except urllib.error.HTTPError as error:
failure = f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')[:200]}"
if error.code not in {429, 500, 502, 503, 504}:
break
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
failure = str(error)
time.sleep(2 ** attempt)
raise RuntimeError(failure)


def metric(result: dict, audit_id: str) -> str:
return result.get("lighthouseResult", {}).get("audits", {}).get(audit_id, {}).get("displayValue", "n/a")


def field_scope(result: dict, name: str) -> str:
metrics = result.get(name, {}).get("metrics", {})
fields = ("LARGEST_CONTENTFUL_PAINT_MS", "INTERACTION_TO_NEXT_PAINT", "CUMULATIVE_LAYOUT_SHIFT_SCORE")
return " / ".join(str(metrics.get(field, {}).get("percentile", "n/a")) for field in fields) if metrics else "not returned"


def make_report(records: list[dict], description: str, selected: int, total: int) -> str:
header = [
"# PageSpeed evidence", "",
f"- Generated (UTC): {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
f"- Scope: {description}", f"- URLs selected: {selected} of {total}",
"- Lab data: Lighthouse. Field data: CrUX only when returned by the response.", "",
"| Requested URL | Final URL | Device | Performance | LCP | INP | CLS | TBT | Page CrUX | Origin CrUX | Status |",
"| --- | --- | --- | ---: | --- | --- | --- | --- | --- | --- | --- |",
]
for record in records:
if record["error"]:
row = [record["url"], "n/a", record["strategy"], "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", record["error"]]
else:
result = record["result"]
lighthouse = result.get("lighthouseResult", {})
perf = lighthouse.get("categories", {}).get("performance", {}).get("score")
row = [record["url"], lighthouse.get("finalUrl", record["url"]), record["strategy"], "n/a" if perf is None else str(round(perf * 100)), *(metric(result, audit) for audit in AUDITS), field_scope(result, "loadingExperience"), field_scope(result, "originLoadingExperience"), "ok"]
header.append("| " + " | ".join(str(item).replace("|", "/") for item in row) + " |")
return "
".join(header) + "
"


def main() -> int:
parser = argparse.ArgumentParser()
choice = parser.add_mutually_exclusive_group(required=True)
choice.add_argument("--url")
choice.add_argument("--urls-file")
choice.add_argument("--sitemap")
parser.add_argument("--max-urls", type=int, default=10)
parser.add_argument("--out", required=True)
args = parser.parse_args()
key = os.environ.get("PAGESPEED_API_KEY")
if not key:
parser.error("PAGESPEED_API_KEY is required in the environment")
output = Path(args.out)
if Path("reports/pagespeed") not in (output, *output.parents):
parser.error("--out must be beneath reports/pagespeed/")
if args.url:
urls, description, total = [args.url], "single URL", 1
elif args.urls_file:
urls = [line.strip() for line in Path(args.urls_file).read_text(encoding="utf-8").splitlines() if line.strip() and not line.startswith("#")]
description, total = "supplied URL list", len(urls)
else:
discovered = sitemap(args.sitemap)
urls, description, total = sample(discovered, args.max_urls), f"sitemap sample from {args.sitemap}", len(discovered)
raw = output / "raw"
raw.mkdir(parents=True, exist_ok=True)
records = []
for number, url in enumerate(urls, 1):
for strategy in STRATEGIES:
try:
result = request_result(url, strategy, key)
(raw / f"{number:03d}-{strategy}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
records.append({"url": url, "strategy": strategy, "result": result, "error": ""})
except RuntimeError as error:
records.append({"url": url, "strategy": strategy, "result": {}, "error": str(error)})
(output / "report.md").write_text(make_report(records, description, len(urls), total), encoding="utf-8")
(output / "summary.json").write_text(json.dumps({"scope": description, "urls": urls, "records": [{key: value for key, value in record.items() if key != "result"} for record in records]}, indent=2), encoding="utf-8")
print(output / "report.md")
return 0


if __name__ == "__main__":
raise SystemExit(main())

Produce an evidence packet, then stop

Set PAGESPEEDAPIKEY in your shell or organization-approved secret manager, never in the repository. Then run a small baseline:

python3 .claude/skills/pagespeed-evidence/scripts/pagespeed_evidence.py \
--url "https://www.example.com/" \
--out reports/pagespeed/homepage-baseline

Expected output: report.md, summary.json, and mobile and desktop raw JSON files. Quality check: git status --short should show no report artifacts. Inspect the report before asking Claude Code to look for source files. If the final URL differs, record that redirect in the implementation brief rather than assuming the requested route is what users see.

Recovery: a 403 usually means the Google API setup or API-key restriction needs attention. An empty field-data section is not a runner error; it means the API did not return eligible CrUX data for that scope. A 429 or 5xx is recorded after bounded retries; rerun later and compare like-for-like scopes instead of mixing partial results with a new audit.

Turn a report into a reviewable implementation brief

The first Claude Code request after an audit should still be read-only. Give it the evidence path and explicitly ask for code candidates, not code edits:

Read reports/pagespeed/homepage-baseline/ as evidence and inspect this repository
read-only. Produce an implementation brief only.

For each prioritized opportunity, cite the relevant report row or raw response,
name candidate templates, components, asset tooling, or configuration files,
and explain why they are candidates. Propose the smallest safe change. Include
expected benefit, risk, local validation, production acceptance criteria, and a
rollback condition. Mark uncertainty clearly.

Do not edit any file, run a formatter, change dependencies, write a test, or
deploy. Wait for my approval of the brief.

Expected decision: a concise plan identifies which hypothesis is worth testing. Quality check: a plan that jumps directly from render-blocking resources to a broad framework rewrite is not ready. Recovery: ask Claude Code to narrow the plan to one page template, one mechanism, and one reversible change, or ask a developer to inspect the raw JSON first.

Make the diff only after approval

After a code owner approves a specific item, give Claude Code a constrained request:

Approved: implement only item P1 from the PageSpeed brief.

Change only these files: [APPROVED PATHS]. Preserve existing behavior. Before
editing, restate the acceptance criteria and rollback condition. After editing,
show git diff, run [APPROVED LOCAL TEST COMMAND], and report any failure.
Do not commit, push, create a pull request, change infrastructure, or deploy.

Quality check: the diff should be smaller than the plan, not larger. It should explain how the local test relates to the performance hypothesis. A green unit test does not prove a Core Web Vitals gain; retest the same PageSpeed scope after a preview or approved release, then compare raw evidence and field-data availability with care.

The evidence-to-change chain

PageSpeed API response
|
v
ignored reports/pagespeed evidence
|
v
read-only repository mapping and repair brief
|
explicit human approval
|
v
small Git diff -> agreed local tests -> preview/retest -> rollback if needed

This is why Claude Code needs a different workflow from a scheduler or chat gateway: it sits close to the codebase. Keeping the evidence, plan, and diff as separate artifacts makes a fast agent easier to review.

Evidence-to-change sequence from PageSpeed response through an evidence packet, read-only mapping, human approval, a small Git diff, testing, and rollback.

Treat evidence, plan, diff, and retest as separate reviewable states.

Verification checklist

  • [ ] The skill is project-local at .claude/skills/pagespeed-evidence/SKILL.md.
  • [ ] The repository has a visible PageSpeed audit policy and an ignored reports/pagespeed/ location.
  • [ ] The API key is only in a local approved secret environment.
  • [ ] Each audited URL has mobile and desktop results, raw JSON, final URL, and failures if any.
  • [ ] Lighthouse measurements and page/origin CrUX data are labeled separately.
  • [ ] Claude Code produced an implementation brief before it edited source files.
  • [ ] Approved changes have a small Git diff, local test result, acceptance criteria, and rollback condition.
  • [ ] No audit or implementation request deploys the website.

Frequently asked questions

Is CLAUDE.md enough to prevent Claude Code from changing files?

No. It is persistent instruction context, which is valuable but not an enforcement mechanism. Use your repository's permissions and hooks when an action must be technically blocked. The policy is still worth keeping because it makes the intended operating model clear to people and the agent.

Can the skill audit an entire site?

It can use a larger list, but a PageSpeed test per sitemap URL is rarely the right first move. Select representative templates, major conversion paths, and recent release surfaces. State the sample rule and expand only when the result calls for it.

Why not let Claude Code fix every Lighthouse opportunity automatically?

Many Lighthouse opportunities describe symptoms, not a universally safe change. Deferring a script can break checkout, consent, analytics, personalization, or accessibility. The audit should generate hypotheses; code owners decide what is safe to test.

Does a better Lighthouse score prove that real users improved?

No. It strengthens evidence for the controlled test condition. Review CrUX field data when it is available and compare the same URLs and release conditions over time.

Official references

Author: Julian Mercer, Technical SEO Practitioner at Auspia. Julian focuses on technical workflows that leave a clear evidence trail from an SEO finding to a reviewed website change.

Explore this topic

Keep following the same growth thread