Audit a site for agent readability
One page's score is a curiosity. A whole site's, sorted worst first, is a work queue — and the penalties say what the work is. Scoring is free; the whole audit costs one credit.
Score the whole site
[02]Map to find the pages, then /v1/score on each. Scoring costs nothing and returns the penalties; /v1/batch doesn't, so it can't tell you why a page scored low.
const post = (path: string, body: unknown) =>
fetch(`https://api.buildonto.dev${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ONTO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}).then((r) => r.json());
// 1 credit: find the pages
const { urls } = await post("/v1/map", { url: "https://vercel.com", limit: 200 });
// free: score them. Each call holds one concurrent slot, so run as many at
// once as your plan has — 2 on Free, 5 on Starter, 20 on Growth.
const SLOTS = 2;
const scored: { url: string; score: number; grade: string; penalties: string[] }[] = [];
const failed: { url: string; code: string }[] = [];
for (let i = 0; i < urls.length; i += SLOTS) {
await Promise.all(
urls.slice(i, i + SLOTS).map(async (url: string) => {
const r = await post("/v1/score", { url });
if (r.status === "error") failed.push({ url, code: r.code });
else if (r.aio_score !== null) // null for PDFs and plain text
scored.push({ url, score: r.aio_score, grade: r.grade, penalties: r.penalties });
}),
);
}Rank what’s worst
[03]The average is the least useful number here. You want the tail — and which one fault explains the most pages.
scored.sort((a, b) => a.score - b.score);
console.log("worst ten:");
for (const p of scored.slice(0, 10)) {
console.log(` ${String(p.score).padStart(3)} ${p.grade.padEnd(11)} ${p.url}`);
}
// which fault costs the most pages? Some penalties carry a count
// ("3 images missing alt text"), so fold the digits before counting.
const byFault = new Map<string, number>();
for (const p of scored) {
for (const text of p.penalties) {
const fault = text.replace(/\d+/g, "#");
byFault.set(fault, (byFault.get(fault) ?? 0) + 1);
}
}
console.log("\nmost common faults:");
for (const [fault, n] of [...byFault].sort((a, b) => b[1] - a[1])) {
console.log(` ${String(n).padStart(4)} pages ${fault}`);
}
// pages that never got a score are the first thing to fix
console.log("\nnot scored:", failed.map((f) => `${f.code} ${f.url}`));Read the penalties
[04]Every page starts at 100 and loses points per fault, so scores move in fives. Through the API, four faults can apply. Try fixing them on a made-up site — hover one to see which pages have it:
- No JSON-LDAdd it to the layout once and every page on it moves.17 pages · −20
- No Markdown version served“Unmanaged AI Payload” — the Serve SDK clears it on every page at once.24 pages · −10
- Fewer than 2 headingsFewer than two h1–h3 on the page.6 pages · −10
- Images without alt textAny image with no alt attribute.10 pages · −5
Without the SDK every page keeps the −10, so 90 is the ceiling until agents get Markdown from your own site.
Fix in this order
[05]- Pages that didn't score. A blocked page doesn't score low — it comes back as
ROBOTS_BLOCKEDorWAF_BLOCKEDand never reaches your ranking. Check thefailedlist first: one robots.txt or firewall rule can hide hundreds of pages. - Then whatever moves the most points. Pages × points, as on the board above. It's usually a template fault — JSON-LD or headings live in a layout, so one change moves every page that uses it.
- Then the payload. The −10 for not serving Markdown is on every page until the Serve SDK is installed, and then it's gone everywhere at once.
Prove it moved
[06]Keep the first run. Deploy the fix, run the same script again, and diff by URL.
const before = new Map(previousRun.map((p) => [p.url, p.score]));
for (const p of scored) {
const was = before.get(p.url);
if (was !== undefined && p.score !== was) {
console.log(`${was} → ${p.score} ${p.url}`);
}
}