From hakuto
Live performance audit for deployed pages using Google PageSpeed Insights API. Runs Lighthouse against public URLs and scores them across Performance, Accessibility, Best Practices, and SEO, plus Core Web Vitals (LCP, INP, CLS, FCP, TTFB) and the top opportunities to improve each. Report-only — no fixes applied. Use when user requests "run pagespeed", "test core web vitals", "audit performance", "check Lighthouse score", "run a performance audit", or "test how fast the site is".
How this skill is triggered — by the user, by Claude, or both
Slash command
/hakuto:pagespeed-auditThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Run Google's PageSpeed Insights against deployed pages and report findings.
Run Google's PageSpeed Insights against deployed pages and report findings.
Scope: Tests one or more public URLs. Audits both mobile and desktop strategies by default.
Critical constraint: PSI fetches the URL from Google's servers, so it
cannot reach localhost, 127.0.0.1, or private network addresses. If
the user provides such a URL, stop immediately and explain — point them at
their deployed staging or production URL instead.
Parse the current invocation for URL(s) — never carry over a URL from a previous turn or assume from prior conversation context. If the user didn't pass a URL on this invocation, you must ask before fetching.
URL provided in this invocation:
No URL provided in this invocation:
AGENTS.md to find a documented production URL or page list.Localhost / private addresses → reject:
http://localhost:*, http://127.0.0.1:*, http://0.0.0.0:*10.*, 192.168.*, 172.16-31.*)*.local, *.internalResolve the key from (in order): shell env, .env.local, .env. All
three are checked in one Bash call:
KEY="${PAGESPEED_API_KEY:-}"
for f in .env.local .env; do
[ -n "$KEY" ] && break
[ -f "$f" ] && KEY=$(grep -E '^PAGESPEED_API_KEY=' "$f" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
done
# Don't echo the key value — print only presence to keep secrets out of chat logs.
[ -n "$KEY" ] && echo "FOUND" || echo "MISSING"
If a key is returned → use it as &key=<value> on every request URL.
If MISSING → stop and tell the user how to get one. Despite the
v5 docs implying anonymous calls work, Google now sets the default
per-day quota for unauthenticated requests to 0. Every call returns
HTTP 429 RESOURCE_EXHAUSTED with quota_limit_value: "0".
Show the user this exact message:
PageSpeed Insights requires an API key. Get a free one in ~1 minute (no billing required):
Open https://developers.google.com/speed/docs/insights/v5/get-started
Click "Get a Key" — it uses your Google account and returns a key.
Save it to an environment file in the repo root:
cp .env.example .env.local # if .env.local doesn't exist yet # then edit .env.local and paste your key after PAGESPEED_API_KEY=Re-run this skill. (
.env.localis gitignored — safe for secrets.)The key gives you 25,000 requests/day, free.
Then stop. Do not attempt the call.
Tell the user: "Running PSI for N URL(s) × 2 strategies. Each call takes 10–30s."
Use curl, NOT WebFetch. WebFetch processes the response through a
small summarisation model that silently drops fields and can fabricate
numeric values for a 200KB+ JSON like PSI's. We need byte-exact JSON.
First verify jq is installed (used in Step 4):
command -v jq >/dev/null 2>&1 || echo "MISSING_JQ"
If MISSING_JQ, stop and tell the user: jq is required for parsing.
Install with brew install jq (macOS) or apt install jq (Linux), then
re-run.
For each URL × strategy (mobile, desktop), fetch raw JSON to a temp file. Run both strategies in parallel (one Bash call each, not chained):
KEY="<resolved key from Step 1>"
URL_ENC="<URL-encoded target URL>" # use bash printf '%s\n' "$URL" | jq -sRr @uri
curl -sS -o "/tmp/psi-${strategy}.json" \
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${URL_ENC}&strategy=${strategy}&category=performance&category=accessibility&category=best-practices&category=seo&key=${KEY}"
Each call takes 10–30s. If curl exits non-zero, or the response file
contains a top-level .error field, treat as failure (see Error
Handling) and continue with the other strategy/URL.
Run this jq script against each saved JSON. The output is the source of truth — do NOT re-summarise via any model.
jq '{
scores: {
performance: (.lighthouseResult.categories.performance.score * 100 | round),
accessibility: (.lighthouseResult.categories.accessibility.score * 100 | round),
best_practices: (.lighthouseResult.categories["best-practices"].score * 100 | round),
seo: (.lighthouseResult.categories.seo.score * 100 | round)
},
lab: {
LCP: .lighthouseResult.audits["largest-contentful-paint"].displayValue,
LCP_ms: .lighthouseResult.audits["largest-contentful-paint"].numericValue,
CLS: .lighthouseResult.audits["cumulative-layout-shift"].displayValue,
CLS_value: .lighthouseResult.audits["cumulative-layout-shift"].numericValue,
TBT: .lighthouseResult.audits["total-blocking-time"].displayValue,
TBT_ms: .lighthouseResult.audits["total-blocking-time"].numericValue,
FCP: .lighthouseResult.audits["first-contentful-paint"].displayValue,
FCP_ms: .lighthouseResult.audits["first-contentful-paint"].numericValue,
SI: .lighthouseResult.audits["speed-index"].displayValue,
SI_ms: .lighthouseResult.audits["speed-index"].numericValue
},
field: (
if (.loadingExperience.metrics // {}) | length > 0 then {
LCP_ms: .loadingExperience.metrics.LARGEST_CONTENTFUL_PAINT_MS.percentile,
CLS: ((.loadingExperience.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE.percentile // 0) / 100),
INP_ms: .loadingExperience.metrics.INTERACTION_TO_NEXT_PAINT.percentile,
origin_fallback: (.loadingExperience.origin_fallback // false)
} else "unavailable" end
),
opportunities: [
.lighthouseResult.audits | to_entries[]
| select(.value.details.type == "opportunity" and (.value.numericValue // 0) > 0)
| { id: .key, title: .value.title, savings: .value.displayValue,
numericValue: .value.numericValue,
urls: [.value.details.items[]?.url // empty] | .[0:2] }
] | sort_by(-.numericValue) | .[0:5],
diagnostics: [
.lighthouseResult.audits | to_entries[]
| select(.value.details.type == "diagnostic" and .value.displayValue and .value.displayValue != "")
| { id: .key, title: .value.title, value: .value.displayValue, score: .value.score }
] | sort_by(.score // 1) | .[0:5],
error: .error // null
}' "/tmp/psi-${strategy}.json"
The shape is stable; categorise the output via Severity Rules in Step 5.
Hand-off note (Step 6): quote the exact displayValue strings from
PSI in the user-facing report. Don't re-format them ("4.7 s" → "4.7s" is
fine, but don't recompute or paraphrase numbers).
Apply Severity Rules below. A page is critical if any Core Web Vital is critical or the performance score is < 50. A page is warning if any metric is in the warning band. Otherwise pass.
Before listing fixes, filter through Known False Positives (next section). Flag those separately as "expected" rather than as actionable fixes — recommending the user remove an intentional directive is worse than the warning itself.
Use the Output Format below. One report per URL. Include both Mobile and Desktop sections. Always include the public PageSpeed Insights deeplink so the user can verify in the browser.
Hakuto-shipped defaults that trigger Lighthouse warnings by design. When you see one of these, surface it as "Expected (intentional)" in the report — do not recommend removing it.
robots.txt — Content-Signal: … (SEO category)robots-txt ("robots.txt is not valid")Unknown directive on a Content-Signal: ai-train=…, search=…, ai-input=… line.Content-Signal is from the Cloudflare / IETF
draft
for declaring AI-training / search / AI-input preferences. Lighthouse's
validator only knows the original 1994 robots.txt grammar and reports
any unrecognised directive as an error.Content-Signal as an HTTP
response header (public/_headers), not as a robots.txt
directive — so this audit should not fire on freshly-scaffolded sites.
If you see it, the site has likely re-introduced the line into
public/robots.txt (older scaffold versions did this). The fix is to
move it back to _headers. If the user wants it in robots.txt for
crawlers that read directives there, surface this audit as expected
/ intentional and don't recommend removal.When future false positives are confirmed (e.g. another non-standard but valid directive Hakuto ships), append them here with the same shape.
Using Google's official Core Web Vitals thresholds.
| Metric | Pass (✅) | Warning (⚠️) | Critical (❌) |
|---|---|---|---|
| Performance score | ≥ 90 | 50–89 | < 50 |
| LCP | ≤ 2.5s | 2.5–4s | > 4s |
| CLS | ≤ 0.1 | 0.1–0.25 | > 0.25 |
| INP (field) | ≤ 200ms | 200–500ms | > 500ms |
| TBT (lab) | ≤ 200ms | 200–600ms | > 600ms |
| FCP | ≤ 1.8s | 1.8–3s | > 3s |
| Speed Index | ≤ 3.4s | 3.4–5.8s | > 5.8s |
| Accessibility / Best Practices / SEO score | ≥ 90 | 80–89 | < 80 |
Render one block per URL with both Mobile and Desktop sections, then a Suggested Fixes list, an Expected (intentional) list for filtered false positives, and the public PSI deeplinks. When auditing multiple URLs, repeat the per-URL block and add a top-level summary table.
See references/example-report.md for the full template — match its shape and headings verbatim.
INVALID_ARGUMENT → URL is malformed or unreachable. Show
the API error message verbatim and ask the user to verify the URL.PAGESPEED_API_KEY was unset, Step 1
should have caught this — the anonymous quota is 0/day. If the key was
set, the user has hit their 25,000/day limit; suggest waiting until
midnight Pacific or requesting a quota increase.loadingExperience missing → Note "Field data unavailable" but
still report lab metrics.Check API key:
echo "${PAGESPEED_API_KEY:-}"
Fetch results: curl to a temp file (NOT WebFetch — see Step 3 for
why). Parse: jq against the saved file (see Step 4 for the script).
Read page list (optional): use the Read tool on AGENTS.md.
localhost or private networks.jq is a hard dependency. Pre-flight check in Step 3. WebFetch is
not safe for PSI responses — its summarisation layer fabricates
category scores and drops Core Web Vital fields.PAGESPEED_API_KEY from env; if absent, it stops with
a "how to get a free key" message.src/ paths.prelaunch-checklist — run after deploy to catch
regressions before announcing the launch.npx claudepluginhub teamniteo/hakuto --plugin hakutoCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.