Audit a site[01]

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.

POST /v1/score[ts]
audit.ts
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:

A made-up site · 24 pages, worst first
71 average55 lowest0 below 50
  1. No JSON-LDAdd it to the layout once and every page on it moves.
    17 pages · −20
  2. No Markdown version served“Unmanaged AI Payload” — the Serve SDK clears it on every page at once.
    24 pages · −10
  3. Fewer than 2 headingsFewer than two h1–h3 on the page.
    6 pages · −10
  4. 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]
  1. Pages that didn't score. A blocked page doesn't score low — it comes back as ROBOTS_BLOCKED or WAF_BLOCKED and never reaches your ranking. Check the failed list first: one robots.txt or firewall rule can hide hundreds of pages.
  2. 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.
  3. 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}`);
  }
}