{
  "$comment": "Canonical, machine-readable rubric. English is the only source language. The score, the report, the per-check pages and the published skills are all generated from this file.",
  "version": "0.7",
  "updated": "2026-10-12",
  "changes": [
    {
      "version": "0.7",
      "date": "2026-10-12",
      "outcome": [
        {
          "checks": [
            "llms-txt"
          ],
          "change": "The link sample counts requests, not positions. A link on another origin, or one the scan already requested, no longer uses up the sample. A requested link that gets no answer is not measured, so it no longer makes the check partial."
        },
        {
          "checks": [
            "mcp-card"
          ],
          "change": "A streamable-http remote that answers 405 or 406 to a plain GET counts as a running server. So does any remote that answers 401 with a challenge naming resource_metadata. A bare 401 still does not count."
        },
        {
          "checks": [
            "mcp-card"
          ],
          "change": "The remotes sampled are those of the first card that offers a transport, which is the card the check reads."
        },
        {
          "checks": [
            "mcp-card",
            "api-catalog",
            "agent-skills",
            "oauth-discovery",
            "status-page",
            "docs",
            "sandbox-environment"
          ],
          "change": "A URL is on the scanned origin only when its scheme, host and port all match. A URL on another port or scheme is no longer judged by the scanned origin’s response."
        },
        {
          "checks": [
            "oauth-discovery"
          ],
          "change": "The scan requests the metadata of a same-origin authorization server named by protected resource metadata. When that metadata was requested and is missing, the check is partial."
        },
        {
          "checks": [
            "docs"
          ],
          "change": "The scan requests documentation pointers on the homepage, service-doc first, up to a published cap. It does not request API catalog targets. An anchor counts only when a whole path segment names a documentation section, and its text no longer counts. A requested pointer that gives no response earns no partial credit."
        },
        {
          "checks": [
            "breadcrumb"
          ],
          "change": "The last entry of a trail may omit item, as Google’s breadcrumb guidance allows. A value that is not a URL is still a defect."
        },
        {
          "checks": [
            "dns-aid"
          ],
          "change": "A record of another type in the answer, such as the CNAME of an alias, no longer counts as a DNS-AID record. When the DNS-over-HTTPS resolver answers no question, the check is not measured. It used to fail, or be judged on the TXT records the system resolver returned."
        },
        {
          "checks": [],
          "change": "The probe phase stops at a whole-scan deadline. A scan that reaches it is reported as not measured, with no score, because part of the site was never requested."
        }
      ],
      "reporting": [
        {
          "checks": [
            "security-txt",
            "openapi",
            "api-catalog"
          ],
          "change": "The detail adds sentences prefixed “Not scored:” about the endpoints the document declares. They never change the status or the points."
        },
        {
          "checks": [
            "llms-txt"
          ],
          "change": "The detail counts the links the scan requested and names each link that did not answer 2xx with the status it answered."
        },
        {
          "checks": [
            "mcp-card"
          ],
          "change": "The detail names the remotes the scan did not request, instead of saying it requested them."
        },
        {
          "checks": [
            "openapi"
          ],
          "change": "A partial names the missing info.title or info.version, instead of saying the document has no callable operation."
        },
        {
          "checks": [
            "dns-aid"
          ],
          "change": "One TXT record that both resolvers return counts once in the detail."
        },
        {
          "checks": [
            "dns-aid"
          ],
          "change": "The evidence names the DNS names and record types the scan queried, instead of agent names it never queried. A fail names the same names in its detail."
        },
        {
          "checks": [],
          "change": "A check that raises an internal error is reported as not measured, in one sentence the health endpoint recognises. Every detail and evidence line is cut to one published length."
        },
        {
          "checks": [],
          "change": "A record counts the markdown negotiation request in its request total and request log."
        }
      ],
      "text": [
        {
          "checks": [
            "reachable"
          ],
          "change": "The measures text names the homepage statuses that make the whole scan not measured, which the engine already did."
        },
        {
          "checks": [
            "llms-txt"
          ],
          "change": "The measures text names every partial the check returns, including a missing blockquote and an H1 after other lines."
        },
        {
          "checks": [
            "agent-skills"
          ],
          "change": "The measures text states the fail clauses the check already returned: no index, or an artifact that does not match its digest."
        },
        {
          "checks": [
            "mcp-card",
            "docs",
            "oauth-discovery"
          ],
          "change": "The measures text describes the requests the scan now makes for these checks."
        },
        {
          "checks": [
            "dns-aid"
          ],
          "change": "The measures text names _agent.<domain>, which the scan already queried, instead of agent names it never queried. It also says when the check is not measured."
        }
      ]
    }
  ],
  "totals": {
    "checks": 61,
    "skills": 60,
    "categories": 5,
    "profiles": 8,
    "byTier": {
      "essential": 16,
      "emerging": 17,
      "recommended": 28
    },
    "byScope": {
      "universal": 46,
      "commercial": 6,
      "profile": 9
    },
    "byMaturity": {
      "adopted": 27,
      "draft": 26,
      "experimental": 8
    }
  },
  "tiers": {
    "essential": 80,
    "recommended": 20,
    "emerging": 5
  },
  "grades": [
    {
      "grade": "AAA",
      "min": 90,
      "label": "near-complete"
    },
    {
      "grade": "AA",
      "min": 75,
      "label": "strong"
    },
    {
      "grade": "A",
      "min": 60,
      "label": "solid"
    },
    {
      "grade": "BBB",
      "min": 45,
      "label": "partial"
    },
    {
      "grade": "BB",
      "min": 30,
      "label": "thin"
    },
    {
      "grade": "B",
      "min": 15,
      "label": "minimal"
    },
    {
      "grade": "C",
      "min": 5,
      "label": "barely started"
    },
    {
      "grade": "D",
      "min": 0,
      "label": "nothing measurable"
    }
  ],
  "levels": [
    {
      "level": 0,
      "name": "Not Ready",
      "min": 0,
      "blurb": "Agents cannot read, verify or use this site."
    },
    {
      "level": 1,
      "name": "Basic Web Presence",
      "min": 14,
      "blurb": "The site is reachable, but there are no agent-specific signals."
    },
    {
      "level": 2,
      "name": "Bot-Aware",
      "min": 30,
      "blurb": "Agent traffic is recognised and routed deliberately."
    },
    {
      "level": 3,
      "name": "Agent-Readable",
      "min": 48,
      "blurb": "Content is genuinely machine-readable."
    },
    {
      "level": 4,
      "name": "Agent-Integrated",
      "min": 66,
      "blurb": "The service is exposed as a callable, authorisable tool."
    },
    {
      "level": 5,
      "name": "Agent-Native",
      "min": 82,
      "blurb": "Agents are first-class users: cryptographic identity and commerce protocols."
    }
  ],
  "levelRequirements": {
    "1": {
      "checks": [
        "reachable"
      ],
      "note": "The site must first respond to agent requests."
    },
    "2": {
      "checks": [
        "robots-txt",
        "ai-rules",
        "content-signal",
        "sitemap-xml"
      ],
      "note": "Recognise agent traffic and route it deliberately."
    },
    "3": {
      "checks": [
        "llms-txt",
        "markdown-negotiation",
        "headings",
        "meta",
        "jsonld",
        "no-js-fallback"
      ],
      "note": "Present content in a form a machine can read."
    },
    "4": {
      "checks": [
        "mcp-card",
        "oauth-discovery",
        "api-catalog",
        "agent-permissions",
        "openapi"
      ],
      "note": "Make your service callable and authorisable."
    },
    "5": {
      "checks": [
        "webbotauth",
        "agent-permissions",
        "a2a-card",
        "payment-protocols",
        "product-schema"
      ],
      "note": "Cryptographic identity and commerce protocols."
    }
  },
  "categories": [
    {
      "id": "protocol",
      "label": "Protocol Discovery",
      "short": "Protocol",
      "weight": 0.25,
      "order": 1,
      "question": "Do you expose your service as a callable tool and an authorisable API?",
      "blurb": "MCP server card, A2A agent card, OAuth discovery, OpenAPI, Agent Skills, WebMCP. The layer that lets an agent use you instead of guessing at you."
    },
    {
      "id": "content",
      "label": "Content Accessibility",
      "short": "Content",
      "weight": 0.22,
      "order": 2,
      "question": "Is your content genuinely readable as markdown, semantic structure and structured data?",
      "blurb": "Server-side rendering, markdown negotiation, semantic headings, JSON-LD. Adobe measured that 39% of retail product pages cannot be read by a machine."
    },
    {
      "id": "discoverability",
      "label": "Discoverability",
      "short": "Discovery",
      "weight": 0.2,
      "order": 3,
      "question": "Can an agent find you, reach the right page and understand what you offer?",
      "blurb": "Machine discovery through robots.txt, sitemaps, DNS and link headers. The cheapest, highest-return layer of agent readiness."
    },
    {
      "id": "botaccess",
      "label": "Bot Access Control",
      "short": "Access",
      "weight": 0.18,
      "order": 4,
      "question": "Can you tell agent traffic apart by identity?",
      "blurb": "Cryptographic agent verification, content usage signals and an explicit permission model. Being blocked by IP is not a policy."
    },
    {
      "id": "commerce",
      "label": "Commerce Protocols",
      "short": "Commerce",
      "weight": 0.15,
      "order": 5,
      "conditional": true,
      "question": "Which standard do you accept agent-initiated transactions over?",
      "blurb": "ACP, AP2, x402, UCP/MPP, product schema and machine-readable returns. Excluded from the denominator unless the site shows a commercial signal."
    }
  ],
  "profiles": {
    "version": "1.1",
    "baseline": {
      "protocol": 0.25,
      "content": 0.22,
      "discoverability": 0.2,
      "botaccess": 0.18,
      "commerce": 0.15
    },
    "thresholds": {
      "minSignalScore": 3,
      "minMargin": 2,
      "strongSignalWeight": 3,
      "weakSignalWeight": 1,
      "note": "A profile is selected when its total signal weight reaches minSignalScore and it is the highest scorer. Otherwise the site is evaluated as 'general'."
    },
    "policy": {
      "principle": "No profile-specific demand without a positively identified profile. Failing to understand a site must never turn into work for its owner.",
      "identified": "The profile's weights, not-applicable list and level requirements apply. Profile-scoped checks are in scope.",
      "ambiguous": "Baseline weights apply. Profile-scoped checks are NOT scored and leave the denominator. Both candidate types are reported and the owner is invited to declare one.",
      "unknown": "Baseline weights apply. Profile-scoped checks are NOT scored and leave the denominator. The report states plainly that the site type could not be determined.",
      "override": "A site owner may declare the type. A declared profile is treated as identified and the record states that it was declared rather than detected.",
      "why": [
        "Demanding a product feed from a news publisher, or a booking action from a developer platform, is a false positive that costs the site owner real work.",
        "The cost of a missed optimisation is smaller than the cost of an incorrect instruction, because an incorrect instruction spends the site owner's trust as well as their time.",
        "So a check that only applies to some site types stays out of scope unless the type was positively identified — and the report says which checks were left out and why."
      ]
    },
    "types": [
      {
        "id": "ecommerce",
        "label": "E-commerce / retail",
        "short": "E-commerce",
        "question": "Can an agent find, price and buy your products without a human?",
        "blurb": "A catalogue that an agent cannot read is a catalogue that does not exist. Product truth, price, availability, returns and a declared checkout path carry most of the weight here.",
        "weights": {
          "commerce": 0.28,
          "protocol": 0.2,
          "content": 0.2,
          "discoverability": 0.18,
          "botaccess": 0.14
        },
        "weightRationale": "Commerce rises to the largest single dimension because a purchase agent that cannot read price, availability or returns cannot transact. Discovery and bot access stay relevant but are not what fails here.",
        "signals": [
          {
            "kind": "jsonld-type",
            "value": "Product",
            "label": "Product schema on the homepage",
            "weight": 3
          },
          {
            "kind": "jsonld-type",
            "value": "Offer",
            "label": "Offer schema on the homepage",
            "weight": 3
          },
          {
            "kind": "jsonld-type",
            "value": "AggregateOffer",
            "label": "AggregateOffer schema on the homepage",
            "weight": 3
          },
          {
            "kind": "path",
            "value": "/cart",
            "label": "A cart path responds",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/checkout",
            "label": "A checkout path responds",
            "weight": 3
          },
          {
            "kind": "copy",
            "value": "add to cart",
            "label": "Purchase-intent copy",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "add to bag",
            "label": "Purchase-intent copy",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/products",
            "label": "A product listing path responds",
            "weight": 1
          },
          {
            "kind": "path",
            "value": "/shop",
            "label": "A shop path responds",
            "weight": 1
          },
          {
            "kind": "jsonld-type",
            "value": "ItemList",
            "label": "A product list schema",
            "weight": 1
          }
        ],
        "notApplicable": [],
        "required": {
          "3": [
            "llms-txt",
            "product-schema",
            "no-js-fallback"
          ],
          "4": [
            "mcp-card",
            "api-catalog",
            "agent-purchase",
            "returns"
          ],
          "5": [
            "payment-protocols",
            "ucp-manifest",
            "webbotauth",
            "inventory-freshness"
          ]
        },
        "extraChecks": [
          "inventory-freshness",
          "seller-identity",
          "shipping-costs"
        ]
      },
      {
        "id": "marketplace",
        "label": "Marketplace / aggregator",
        "short": "Marketplace",
        "question": "Can an agent tell who is selling what, at what price, and buy it?",
        "blurb": "A marketplace has to expose two things an ordinary shop does not: seller identity and per-offer availability across many vendors.",
        "weights": {
          "commerce": 0.24,
          "protocol": 0.24,
          "content": 0.18,
          "discoverability": 0.18,
          "botaccess": 0.16
        },
        "weightRationale": "Commerce and protocol share the weight: the transaction surface and the seller/offer contract matter equally, and neither can be inferred from the other.",
        "signals": [
          {
            "kind": "copy",
            "value": "marketplace",
            "label": "Describes itself as a marketplace",
            "weight": 3
          },
          {
            "kind": "copy",
            "value": "sellers",
            "label": "Refers to sellers",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/sell",
            "label": "A seller onboarding path responds",
            "weight": 3
          },
          {
            "kind": "jsonld-type",
            "value": "AggregateOffer",
            "label": "AggregateOffer schema",
            "weight": 2
          },
          {
            "kind": "jsonld-type",
            "value": "Product",
            "label": "Product schema",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "free shipping",
            "label": "Shipping signals",
            "weight": 1
          }
        ],
        "notApplicable": [],
        "required": {
          "3": [
            "llms-txt",
            "product-schema"
          ],
          "4": [
            "mcp-card",
            "api-catalog",
            "agent-purchase",
            "seller-identity"
          ],
          "5": [
            "payment-protocols",
            "ucp-manifest",
            "inventory-freshness",
            "webbotauth"
          ]
        },
        "extraChecks": [
          "inventory-freshness",
          "seller-identity",
          "shipping-costs"
        ]
      },
      {
        "id": "saas",
        "label": "SaaS / software product",
        "short": "SaaS",
        "question": "Can an agent understand what the product does, what it costs, and how to call it?",
        "blurb": "The agent's job here is evaluation and integration, not purchase. Pricing truth, a machine-readable API contract and a callable tool surface dominate.",
        "weights": {
          "protocol": 0.3,
          "content": 0.22,
          "discoverability": 0.18,
          "botaccess": 0.18,
          "commerce": 0.12
        },
        "weightRationale": "Protocol rises because the product is a service: an agent that cannot discover the API or the MCP surface cannot do anything with the site at all. Commerce drops to a minor dimension.",
        "signals": [
          {
            "kind": "jsonld-type",
            "value": "SoftwareApplication",
            "label": "SoftwareApplication schema",
            "weight": 3
          },
          {
            "kind": "path",
            "value": "/pricing",
            "label": "A pricing page responds",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "free trial",
            "label": "Trial signup copy",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/docs",
            "label": "A documentation path responds",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "api key",
            "label": "API credential copy",
            "weight": 2
          },
          {
            "kind": "jsonld-type",
            "value": "Service",
            "label": "Service schema",
            "weight": 1
          }
        ],
        "notApplicable": [],
        "required": {
          "3": [
            "llms-txt",
            "pricing-structured",
            "docs"
          ],
          "4": [
            "mcp-card",
            "openapi",
            "oauth-discovery",
            "auth-md"
          ],
          "5": [
            "webbotauth",
            "a2a-card",
            "sandbox-environment",
            "webmcp"
          ]
        },
        "extraChecks": [
          "sandbox-environment"
        ]
      },
      {
        "id": "api",
        "label": "API / developer platform",
        "short": "API platform",
        "question": "Can an agent discover, authorise against and call the API without reading a tutorial?",
        "blurb": "For a developer platform the site is a means to an endpoint. Contract, authorisation and tool exposure are the product.",
        "weights": {
          "protocol": 0.34,
          "content": 0.2,
          "discoverability": 0.18,
          "botaccess": 0.18,
          "commerce": 0.1
        },
        "weightRationale": "The strongest protocol weight in the model. Discovery, contract and authorisation are what an integrating agent needs; commerce is almost incidental.",
        "signals": [
          {
            "kind": "path",
            "value": "/openapi.json",
            "label": "An OpenAPI document responds",
            "weight": 3
          },
          {
            "kind": "path",
            "value": "/.well-known/openapi.json",
            "label": "A well-known OpenAPI document responds",
            "weight": 3
          },
          {
            "kind": "path",
            "value": "/docs",
            "label": "A documentation path responds",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "rate limit",
            "label": "Rate-limit documentation",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "api reference",
            "label": "API reference copy",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/developers",
            "label": "A developer portal responds",
            "weight": 2
          }
        ],
        "notApplicable": [],
        "required": {
          "3": [
            "llms-txt",
            "docs",
            "openapi"
          ],
          "4": [
            "mcp-card",
            "api-catalog",
            "oauth-discovery",
            "auth-md",
            "agent-skills"
          ],
          "5": [
            "webbotauth",
            "sandbox-environment",
            "a2a-card",
            "webmcp"
          ]
        },
        "extraChecks": [
          "sandbox-environment",
          "rate-limits-declared"
        ]
      },
      {
        "id": "publisher",
        "label": "Content / media / publisher",
        "short": "Publisher",
        "question": "Can an agent read, quote and correctly attribute your content?",
        "blurb": "The content is the product. Machine readability, provenance, licensing and usage signals carry the weight; commerce is present only as licensing.",
        "weights": {
          "content": 0.3,
          "discoverability": 0.24,
          "botaccess": 0.18,
          "protocol": 0.16,
          "commerce": 0.12
        },
        "weightRationale": "Content and discovery dominate. Bot access stays high because a publisher's central decision is who may read and reuse the work.",
        "signals": [
          {
            "kind": "jsonld-type",
            "value": "NewsArticle",
            "label": "NewsArticle schema",
            "weight": 3
          },
          {
            "kind": "jsonld-type",
            "value": "Article",
            "label": "Article schema",
            "weight": 3
          },
          {
            "kind": "jsonld-type",
            "value": "BlogPosting",
            "label": "BlogPosting schema",
            "weight": 3
          },
          {
            "kind": "path",
            "value": "/blog",
            "label": "A blog index responds",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/news",
            "label": "A news index responds",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "subscribe",
            "label": "Subscription copy",
            "weight": 1
          }
        ],
        "notApplicable": [
          "product-schema",
          "payment-protocols",
          "agent-purchase",
          "ucp-manifest",
          "returns",
          "pricing-structured",
          "inventory-freshness",
          "seller-identity",
          "shipping-costs",
          "sandbox-environment",
          "rate-limits-declared"
        ],
        "required": {
          "3": [
            "llms-txt",
            "markdown-negotiation",
            "jsonld",
            "canonical"
          ],
          "4": [
            "content-signal",
            "ai-policy",
            "api-catalog",
            "mcp-card"
          ],
          "5": [
            "licensing-terms",
            "webbotauth",
            "agent-permissions"
          ]
        },
        "extraChecks": [
          "licensing-terms",
          "provenance-declared"
        ]
      },
      {
        "id": "services",
        "$comment": "a2a-card is deliberately NOT in notApplicable. It was, while the same profile required it at L5 — a requirement that could never bite, because an out-of-scope check cannot hold a level back. The requirement is the right half of that pair: the card is how an agent reaches a service business at all, the baseline requires it at L5 too, and the remaining exclusions (openapi, agent-skills, webmcp, ard-manifest, ucp-manifest, docs) are developer-platform artifacts a local business does not publish. a2a-signed stays excluded: signing the card is emerging hardening, and no level gates on it.",
        "label": "Services / local business",
        "short": "Services",
        "question": "Can an agent understand what you offer, where, when and for how much?",
        "blurb": "The agent's task is qualification and booking. Service scope, location, hours, price range and a booking action are the signals that matter.",
        "weights": {
          "discoverability": 0.24,
          "content": 0.24,
          "protocol": 0.18,
          "botaccess": 0.16,
          "commerce": 0.18
        },
        "weightRationale": "Discovery and content lead because the site's job is to answer 'do you do this, here, and what does it cost'. Commerce is non-trivial because quotes and bookings are transactions.",
        "signals": [
          {
            "kind": "jsonld-type",
            "value": "LocalBusiness",
            "label": "LocalBusiness schema",
            "weight": 3
          },
          {
            "kind": "jsonld-type",
            "value": "Service",
            "label": "Service schema",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "book an appointment",
            "label": "Booking copy",
            "weight": 3
          },
          {
            "kind": "copy",
            "value": "get a quote",
            "label": "Quote request copy",
            "weight": 2
          },
          {
            "kind": "path",
            "value": "/contact",
            "label": "A contact path responds",
            "weight": 1
          },
          {
            "kind": "path",
            "value": "/pricing",
            "label": "A pricing path responds",
            "weight": 1
          }
        ],
        "notApplicable": [
          "openapi",
          "agent-skills",
          "webmcp",
          "ard-manifest",
          "a2a-signed",
          "ucp-manifest",
          "product-schema",
          "inventory-freshness",
          "seller-identity",
          "shipping-costs",
          "sandbox-environment",
          "rate-limits-declared",
          "docs"
        ],
        "required": {
          "3": [
            "llms-txt",
            "service-schema",
            "contact"
          ],
          "4": [
            "pricing-structured",
            "mcp-card",
            "jsonld"
          ],
          "5": [
            "booking-action",
            "webbotauth",
            "a2a-card"
          ]
        },
        "extraChecks": [
          "booking-action"
        ]
      },
      {
        "id": "institution",
        "label": "Institution / public sector / education",
        "short": "Institution",
        "question": "Can an agent find the authoritative document, in an accessible form, and cite it?",
        "blurb": "The content is public by mandate. Accessibility, document formats, language declaration and a stable citation surface outrank any protocol ambition.",
        "weights": {
          "content": 0.26,
          "discoverability": 0.22,
          "botaccess": 0.2,
          "protocol": 0.18,
          "commerce": 0.14
        },
        "weightRationale": "Content and discovery lead, and bot access stays high because the material is meant to be read. Protocol is lower: these sites rarely expose callable services.",
        "signals": [
          {
            "kind": "tld",
            "value": ".gov",
            "label": "A government domain",
            "weight": 3
          },
          {
            "kind": "tld",
            "value": ".edu",
            "label": "An education domain",
            "weight": 3
          },
          {
            "kind": "copy",
            "value": "university",
            "label": "University copy",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "municipality",
            "label": "Municipality copy",
            "weight": 2
          },
          {
            "kind": "copy",
            "value": "public records",
            "label": "Public records copy",
            "weight": 2
          },
          {
            "kind": "tld",
            "value": ".org",
            "label": "An organisation domain",
            "weight": 1
          }
        ],
        "notApplicable": [
          "product-schema",
          "payment-protocols",
          "agent-purchase",
          "ucp-manifest",
          "returns",
          "pricing-structured",
          "inventory-freshness",
          "seller-identity",
          "shipping-costs",
          "sandbox-environment",
          "rate-limits-declared"
        ],
        "required": {
          "3": [
            "llms-txt",
            "lang",
            "headings",
            "no-js-fallback"
          ],
          "4": [
            "content-signal",
            "ai-policy",
            "api-catalog",
            "docs"
          ],
          "5": [
            "webbotauth",
            "agent-permissions",
            "licensing-terms"
          ]
        },
        "extraChecks": [
          "document-formats",
          "provenance-declared"
        ]
      },
      {
        "id": "general",
        "label": "General website",
        "short": "General",
        "question": "Can an agent find you, read you and trust you?",
        "blurb": "No strong commercial, editorial or platform signal was detected. The baseline weighting applies and every non-conditional check is in scope.",
        "weights": {
          "protocol": 0.25,
          "content": 0.22,
          "discoverability": 0.2,
          "botaccess": 0.18,
          "commerce": 0.15
        },
        "weightRationale": "The published baseline. Used when no profile reaches the detection threshold, so that an unclassifiable site is still measured rather than skipped.",
        "signals": [],
        "notApplicable": [],
        "required": {
          "3": [
            "llms-txt",
            "headings",
            "meta",
            "no-js-fallback"
          ],
          "4": [
            "mcp-card",
            "api-catalog",
            "agent-permissions"
          ],
          "5": [
            "webbotauth",
            "a2a-card",
            "content-signal"
          ]
        },
        "extraChecks": []
      }
    ]
  },
  "skills": [
    {
      "name": "agent-access",
      "title": "Open agent access (clear the 403/429 block)",
      "standards": [],
      "checks": [
        "reachable"
      ]
    },
    {
      "name": "robots-txt",
      "title": "Publish a valid robots.txt",
      "standards": [
        "RFC 9309"
      ],
      "checks": [
        "robots-txt"
      ]
    },
    {
      "name": "sitemap",
      "title": "Publish a sitemap with lastmod",
      "standards": [],
      "checks": [
        "sitemap-xml"
      ]
    },
    {
      "name": "llms-txt",
      "title": "Publish llms.txt",
      "standards": [
        "llmstxt.org"
      ],
      "checks": [
        "llms-txt"
      ]
    },
    {
      "name": "link-headers",
      "title": "Advertise discovery through Link headers",
      "standards": [
        "RFC 8288"
      ],
      "checks": [
        "link-headers"
      ]
    },
    {
      "name": "dns-aid",
      "title": "Publish a DNS-AID discovery record",
      "standards": [],
      "checks": [
        "dns-aid"
      ]
    },
    {
      "name": "canonical",
      "title": "Establish canonical and hreflang discipline",
      "standards": [],
      "checks": [
        "canonical"
      ]
    },
    {
      "name": "llms-full-txt",
      "title": "Publish llms-full.txt",
      "standards": [],
      "checks": [
        "llms-full"
      ]
    },
    {
      "name": "agents-md",
      "title": "Publish AGENTS.md",
      "standards": [],
      "checks": [
        "agents-md"
      ]
    },
    {
      "name": "sitemap-md",
      "title": "Publish a markdown sitemap",
      "standards": [],
      "checks": [
        "sitemap-md"
      ]
    },
    {
      "name": "markdown-negotiation",
      "title": "Serve markdown on Accept: text/markdown",
      "standards": [
        "RFC 9110"
      ],
      "checks": [
        "markdown-negotiation"
      ]
    },
    {
      "name": "semantic-headings",
      "title": "Build a semantic heading structure",
      "standards": [
        "WCAG 2.2"
      ],
      "checks": [
        "headings"
      ]
    },
    {
      "name": "page-metadata",
      "title": "Add a title and meta description",
      "standards": [],
      "checks": [
        "meta"
      ]
    },
    {
      "name": "structured-data",
      "title": "Add JSON-LD structured data",
      "standards": [
        "schema.org"
      ],
      "checks": [
        "jsonld"
      ]
    },
    {
      "name": "faq-schema",
      "title": "Add FAQ schema",
      "standards": [
        "schema.org/FAQPage"
      ],
      "checks": [
        "faq-schema"
      ]
    },
    {
      "name": "breadcrumb-schema",
      "title": "Add breadcrumb schema",
      "standards": [
        "schema.org/BreadcrumbList"
      ],
      "checks": [
        "breadcrumb"
      ]
    },
    {
      "name": "page-summary",
      "title": "Add a summary block",
      "standards": [],
      "checks": [
        "summary"
      ]
    },
    {
      "name": "language-declaration",
      "title": "Add a language declaration and hreflang",
      "standards": [],
      "checks": [
        "lang"
      ]
    },
    {
      "name": "server-rendering",
      "title": "Serve content that needs no JavaScript",
      "standards": [],
      "checks": [
        "no-js-fallback",
        "spa-fallback"
      ]
    },
    {
      "name": "token-efficiency",
      "title": "Improve the content-to-markup ratio",
      "standards": [],
      "checks": [
        "token-efficiency"
      ]
    },
    {
      "name": "responsive-markup",
      "title": "Add responsive markup",
      "standards": [],
      "checks": [
        "viewport"
      ]
    },
    {
      "name": "web-bot-auth",
      "title": "Publish a Web Bot Auth key directory",
      "standards": [
        "RFC 9421"
      ],
      "checks": [
        "webbotauth"
      ]
    },
    {
      "name": "agent-permissions",
      "title": "Publish an agent permission model",
      "standards": [],
      "checks": [
        "agent-permissions"
      ]
    },
    {
      "name": "ai-rules",
      "title": "Add named AI bot rules to robots.txt",
      "standards": [
        "RFC 9309"
      ],
      "checks": [
        "ai-rules"
      ]
    },
    {
      "name": "content-signals",
      "title": "Declare Content-Signal usage preferences",
      "standards": [
        "contentsignals.org"
      ],
      "checks": [
        "content-signal"
      ]
    },
    {
      "name": "ai-policy",
      "title": "Publish an AI usage policy",
      "standards": [],
      "checks": [
        "ai-policy"
      ]
    },
    {
      "name": "bot-policy",
      "title": "Publish a crawler and agent policy page",
      "standards": [],
      "checks": [
        "bot-page"
      ]
    },
    {
      "name": "security-txt",
      "title": "Publish security.txt",
      "standards": [
        "RFC 9116"
      ],
      "checks": [
        "security-txt"
      ]
    },
    {
      "name": "crawl-delay",
      "title": "Declare a crawl rate",
      "standards": [],
      "checks": [
        "crawl-delay"
      ]
    },
    {
      "name": "ai-txt",
      "title": "Publish an /ai.txt access declaration",
      "standards": [],
      "checks": [
        "ai-txt"
      ]
    },
    {
      "name": "mcp-server-card",
      "title": "Publish an MCP server card",
      "standards": [
        "MCP 2026-07-28"
      ],
      "checks": [
        "mcp-card"
      ]
    },
    {
      "name": "openapi",
      "title": "Publish an OpenAPI definition",
      "standards": [
        "OpenAPI 3.x"
      ],
      "checks": [
        "openapi"
      ]
    },
    {
      "name": "oauth-discovery",
      "title": "Publish OAuth discovery documents",
      "standards": [
        "RFC 8414",
        "RFC 9728"
      ],
      "checks": [
        "oauth-discovery"
      ]
    },
    {
      "name": "api-catalog",
      "title": "Publish an API catalog",
      "standards": [
        "RFC 9727"
      ],
      "checks": [
        "api-catalog"
      ]
    },
    {
      "name": "agent-skills",
      "title": "Publish an agent skills index",
      "standards": [],
      "checks": [
        "agent-skills"
      ]
    },
    {
      "name": "a2a-agent-card",
      "title": "Publish an A2A agent card",
      "standards": [
        "A2A v1.0"
      ],
      "checks": [
        "a2a-card"
      ]
    },
    {
      "name": "a2a-card-signing",
      "title": "Sign the agent card",
      "standards": [
        "RFC 7515"
      ],
      "checks": [
        "a2a-signed"
      ]
    },
    {
      "name": "auth-md",
      "title": "Publish an auth.md guide",
      "standards": [],
      "checks": [
        "auth-md"
      ]
    },
    {
      "name": "webmcp",
      "title": "Declare WebMCP tools",
      "standards": [
        "W3C WebMCP"
      ],
      "checks": [
        "webmcp"
      ]
    },
    {
      "name": "ard",
      "title": "Publish an ARD resource manifest",
      "standards": [
        "ard-spec"
      ],
      "checks": [
        "ard-manifest"
      ]
    },
    {
      "name": "documentation",
      "title": "Publish machine-readable documentation",
      "standards": [],
      "checks": [
        "docs"
      ]
    },
    {
      "name": "pricing-schema",
      "title": "Publish pricing as structured data",
      "standards": [
        "schema.org/Offer"
      ],
      "checks": [
        "pricing-structured"
      ]
    },
    {
      "name": "service-schema",
      "title": "Add a service scope schema",
      "standards": [],
      "checks": [
        "service-schema"
      ]
    },
    {
      "name": "contact-schema",
      "title": "Add structured contact information",
      "standards": [
        "schema.org/Organization"
      ],
      "checks": [
        "contact"
      ]
    },
    {
      "name": "status-changelog",
      "title": "Publish a status and changelog feed",
      "standards": [],
      "checks": [
        "status-page"
      ]
    },
    {
      "name": "product-schema",
      "title": "Add product schema",
      "standards": [
        "schema.org/Product"
      ],
      "checks": [
        "product-schema"
      ]
    },
    {
      "name": "agent-payments",
      "title": "Declare agent payment standard support",
      "standards": [
        "AP2",
        "ACP",
        "x402",
        "UCP"
      ],
      "checks": [
        "payment-protocols"
      ]
    },
    {
      "name": "agent-purchase-policy",
      "title": "Publish an agent purchase policy",
      "standards": [],
      "checks": [
        "agent-purchase"
      ]
    },
    {
      "name": "ucp",
      "title": "Publish a UCP manifest",
      "standards": [],
      "checks": [
        "ucp-manifest"
      ]
    },
    {
      "name": "return-policy-schema",
      "title": "Add return and shipping schema",
      "standards": [
        "schema.org/MerchantReturnPolicy"
      ],
      "checks": [
        "returns"
      ]
    },
    {
      "name": "agent-analytics",
      "title": "Measure agent traffic separately",
      "standards": [],
      "checks": [
        "agent-analytics"
      ]
    },
    {
      "name": "inventory-freshness",
      "title": "Declare availability and price validity",
      "standards": [
        "schema.org/Offer"
      ],
      "checks": [
        "inventory-freshness"
      ]
    },
    {
      "name": "seller-identity",
      "title": "Identify the seller and the merchant of record",
      "standards": [
        "schema.org/Organization"
      ],
      "checks": [
        "seller-identity"
      ]
    },
    {
      "name": "shipping-costs",
      "title": "Publish shipping cost and delivery estimates",
      "standards": [
        "schema.org/OfferShippingDetails"
      ],
      "checks": [
        "shipping-costs"
      ]
    },
    {
      "name": "sandbox-environment",
      "title": "Publish a sandbox environment",
      "standards": [],
      "checks": [
        "sandbox-environment"
      ]
    },
    {
      "name": "rate-limits",
      "title": "Document rate limits and quotas",
      "standards": [
        "RFC 9110"
      ],
      "checks": [
        "rate-limits-declared"
      ]
    },
    {
      "name": "licensing",
      "title": "Declare reuse and licensing terms",
      "standards": [
        "TDM Reservation Protocol"
      ],
      "checks": [
        "licensing-terms"
      ]
    },
    {
      "name": "provenance",
      "title": "Declare author, publication and update dates",
      "standards": [
        "schema.org/author",
        "C2PA"
      ],
      "checks": [
        "provenance-declared"
      ]
    },
    {
      "name": "booking-action",
      "title": "Declare a bookable action",
      "standards": [
        "schema.org/ReserveAction"
      ],
      "checks": [
        "booking-action"
      ]
    },
    {
      "name": "document-formats",
      "title": "Publish documents in machine-readable formats",
      "standards": [
        "PDF/UA"
      ],
      "checks": [
        "document-formats"
      ]
    }
  ],
  "checks": [
    {
      "id": "reachable",
      "category": "discoverability",
      "tier": "essential",
      "weight": 3,
      "impact": "critical",
      "maturity": "adopted",
      "conditional": false,
      "label": "Homepage open to agent access",
      "why": "If an agent cannot read your site, every other check is moot. Cloudflare began blocking AI crawlers by default in 2025, with signed agents routed separately.",
      "skill": "agent-access",
      "skillTitle": "Open agent access (clear the 403/429 block)",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner sends one GET to https://<domain>/ - the homepage is the only source for this check - using a non-browser agent user agent, and records the status code, Content-Type and body size. Pass = a 2xx HTML response with a real body. Partial = a 2xx response that is not HTML, a body smaller than roughly 1 KB or with almost no visible text, or a redirect chain that leaves the host before reaching content. Not measured = 401, 402, 403, 406, 429 or 503, no response, a JavaScript-only shell, or a scan cut at the whole-scan deadline: the whole scan is then reported as not measured, with no score. A refusal is a fact about the scanner's vantage point, not about the site. Fail = any other non-2xx status, such as 404, 451 or another 5xx.",
      "verify": "DOMAIN=example.com\ncurl -sS -o /dev/null -w 'status=%{http_code} type=%{content_type} bytes=%{size_download} redirect=%{redirect_url}\\n' -A 'AgenticWebScore/0.3 (+https://agenticwebscore.com/bot)' \"https://$DOMAIN/\"",
      "fix": "# nginx: clear the 403/429 that agent user agents hit on the homepage.\n# $bot_block and the rate limit come from your existing config; the allow-list\n# below is evaluated first so agents are never sent down the block path.\nmap $http_user_agent $agent_allow {\n    default                                                          0;\n    \"~*(GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|PerplexityBot)\"  1;\n    \"~*(Google-Extended|Applebot-Extended|CCBot|Amazonbot)\"          1;\n}\n\nserver {\n    listen 443 ssl;\n    server_name example.com;\n\n    location = / {\n        if ($agent_allow) { set $bot_block 0; }\n        if ($bot_block)   { return 403; }\n        try_files /index.html =404;\n    }\n}",
      "fixLang": "text",
      "fixNote": "If a CDN or WAF sits in front of nginx - for example Cloudflare's default AI-crawler block - the allow decision must be made there too, because nginx never sees a request the edge rejects."
    },
    {
      "id": "robots-txt",
      "category": "discoverability",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "robots.txt with agent directives",
      "why": "The front door for agent traffic (RFC 9309).",
      "skill": "robots-txt",
      "skillTitle": "Publish a valid robots.txt",
      "standards": [
        "RFC 9309"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$robots"
      ],
      "measures": "The scanner fetches https://<domain>/robots.txt ($robots in the probe map) and parses it per RFC 9309, where a group is a User-agent line followed by its Allow/Disallow rules. Pass = HTTP 200 with a parseable file that names at least one agent crawler as its own group and declares a Sitemap. Partial = the file parses but contains only a blanket User-agent: * group, or places rules before any User-agent line. Fail = 404 or 5xx, a non-text response, or HTML returned for the path by a catch-all route.",
      "verify": "DOMAIN=example.com\ncurl -sSi \"https://$DOMAIN/robots.txt\" | sed -n '1,40p'",
      "fix": "# https://example.com/robots.txt\n# RFC 9309: a group is a User-agent line followed by its rules.\n\nUser-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /cart/\nDisallow: /search?\n\n# Name the agent crawlers you welcome, so agent traffic is routed deliberately.\nUser-agent: GPTBot\nAllow: /\n\nUser-agent: OAI-SearchBot\nAllow: /\n\nUser-agent: ClaudeBot\nAllow: /\n\nUser-agent: PerplexityBot\nAllow: /\n\nSitemap: https://example.com/sitemap.xml",
      "fixLang": "text",
      "fixNote": "Rules must follow their own User-agent line - a Disallow placed before any User-agent is ignored - and robots.txt is only read at the root of the host, never at a subpath."
    },
    {
      "id": "sitemap-xml",
      "category": "discoverability",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "sitemap.xml valid and current",
      "why": "The primary URL discovery source for agent crawlers.",
      "skill": "sitemap",
      "skillTitle": "Publish a sitemap with lastmod",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$sitemap"
      ],
      "measures": "The scanner fetches https://<domain>/sitemap.xml ($sitemap in the probe map) and parses it as XML. Pass = a urlset (or a sitemapindex) whose entries carry absolute loc URLs and a lastmod timestamp. Partial = valid XML with loc entries but no lastmod anywhere, or a sitemapindex that only points at child sitemaps. Fail = 404, an HTML page returned with a 200 by a catch-all route, or XML that does not parse.",
      "verify": "DOMAIN=example.com\ncurl -sS \"https://$DOMAIN/sitemap.xml\" | grep -E '<loc>|<lastmod>' | head -20",
      "fix": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>https://example.com/</loc>\n    <lastmod>2026-10-08</lastmod>\n    <changefreq>weekly</changefreq>\n    <priority>1.0</priority>\n  </url>\n  <url>\n    <loc>https://example.com/docs/quickstart</loc>\n    <lastmod>2026-10-08</lastmod>\n    <changefreq>monthly</changefreq>\n    <priority>0.8</priority>\n  </url>\n  <url>\n    <loc>https://example.com/pricing</loc>\n    <lastmod>2026-09-30</lastmod>\n    <changefreq>monthly</changefreq>\n    <priority>0.6</priority>\n  </url>\n</urlset>",
      "fixLang": "xml",
      "fixNote": "lastmod must be a real modification date in W3C datetime form and must actually change when the page changes, otherwise the whole file reads as stale."
    },
    {
      "id": "llms-txt",
      "category": "discoverability",
      "tier": "essential",
      "weight": 3,
      "impact": "critical",
      "maturity": "draft",
      "conditional": false,
      "label": "/llms.txt published and well-structured",
      "why": "The most widely adopted contract for making a site legible to agents. In the curated corpus we measured, 46% of sites published one and the average quality score was 37 out of 100 — adoption is not the same as usefulness.",
      "skill": "llms-txt",
      "skillTitle": "Publish llms.txt",
      "standards": [
        "llmstxt.org"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/llms.txt"
      ],
      "sources": [],
      "measures": "The scanner fetches https://<domain>/llms.txt (the exact path this check probes) and parses the markdown against the llmstxt.org contract. It then requests up to 6 same-origin links the file names, beyond the paths the scan already requests. A link on another origin is not requested, and a link that gets no answer is not measured. Pass = a 2xx response served as text/plain or text/markdown, opening with an H1, followed by a blockquote summary and at least one H2 section listing markdown links written as [name](url). No requested same-origin link may answer a status other than 2xx, and the pass states how many links were requested. Partial = the file is served with another content type, has no H1, has an H1 after other lines, has no H2 link list, has no blockquote, or names a requested same-origin link that answered a status other than 2xx. Fail = a non-2xx status for the file, or an HTML document returned for the path. A request for the file that gets no answer is not measured.",
      "verify": "DOMAIN=example.com\ncurl -sS \"https://$DOMAIN/llms.txt\" | head -25",
      "fix": "# Example Inc\n\n> Example Inc runs a payments API for marketplaces. This file lists the pages an agent needs, with a short note on each.\n\nExample Inc is US-based; support is at support@example.com.\nAPI access uses OAuth 2.0 tokens, see the authentication guide below.\n\n## Docs\n\n- [Quickstart](https://example.com/docs/quickstart.md): Install the CLI and make a first API call\n- [API reference](https://example.com/docs/api.md): Every endpoint, with request and response examples\n- [Authentication](https://example.com/docs/auth.md): OAuth 2.0 flows and token lifetimes\n\n## Company\n\n- [Pricing](https://example.com/pricing.md): Plans, limits and overage rates\n- [Status](https://example.com/status): Live service status and incident history\n\n## Optional\n\n- [Changelog](https://example.com/changelog.md): Release notes since 2024",
      "fixLang": "markdown",
      "fixNote": "The links should point at markdown versions of the pages, and the file must be served raw - an SPA catch-all that returns index.html for this path scores as a fail."
    },
    {
      "id": "link-headers",
      "category": "discoverability",
      "tier": "emerging",
      "weight": 2,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Discovery via Link response headers",
      "why": "RFC 8288 Link headers let an agent find your discovery resources without parsing HTML.",
      "skill": "link-headers",
      "skillTitle": "Advertise discovery through Link headers",
      "standards": [
        "RFC 8288"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner reads the Link response headers on https://<domain>/ ($home in the probe map) and parses them per RFC 8288: an angle-bracketed URI-reference followed by semicolon-separated parameters. Pass = at least one Link header that advertises a discovery resource with a registered relation, such as rel=describedby pointing at /llms.txt or rel=alternate with type=text/markdown. Partial = a Link header is present but carries no discovery relation (only preload or preconnect, for example). Fail = the homepage response carries no Link header at all.",
      "verify": "DOMAIN=example.com\ncurl -sS -D - -o /dev/null \"https://$DOMAIN/\" | grep -i '^link:'",
      "fix": "Link: </llms.txt>; rel=\"describedby\"; type=\"text/plain\", </sitemap.xml>; rel=\"alternate\"; type=\"application/xml\", </docs/quickstart.md>; rel=\"alternate\"; type=\"text/markdown\"\n\n# nginx equivalent\nadd_header Link '</llms.txt>; rel=\"describedby\"; type=\"text/plain\", </sitemap.xml>; rel=\"alternate\"; type=\"application/xml\"' always;",
      "fixLang": "text",
      "fixNote": "Multiple links belong in one comma-separated Link header; relative targets are legal under RFC 8288 because they resolve against the request URI, but absolute URLs are safer for agents that do not track context."
    },
    {
      "id": "dns-aid",
      "category": "discoverability",
      "tier": "emerging",
      "weight": 2,
      "impact": "medium",
      "maturity": "experimental",
      "conditional": false,
      "label": "DNS-AID discovery record",
      "why": "Lets agents read discovery information over DNS without an HTTP round trip. Cloudflare added it to its readiness checklist.",
      "skill": "dns-aid",
      "skillTitle": "Publish a DNS-AID discovery record",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "The scanner queries DNS-AID records over DNS: SVCB records (RR type 64) at the organization entry point _index._agents.<domain>, as defined by DNS-AID (draft-mozleywilliams-dnsop-dnsaid), and at _agent.<domain>, the older single-record form. It does not query the agent names an index advertises. Pass = at least one SVCB record resolves with a TargetName and at least one SvcParamKey such as well-known, alpn, port or cap. Partial = only the TXT fallback form is published, or a record exists but carries no SvcParamKeys. Fail = NXDOMAIN, or no SVCB and no TXT record at those names. Not measured = the DNS-over-HTTPS resolver answers none of the DNS-AID questions.",
      "verify": "DOMAIN=example.com\ndig +short TYPE64 _index._agents.$DOMAIN; dig +short TYPE64 assistant.$DOMAIN",
      "fix": "; DNS-AID records for example.com (draft-mozleywilliams-dnsop-dnsaid)\n\n; Organization index entry point: the agents this domain advertises.\n_index._agents.example.com. 3600 IN SVCB 1 agent-index.example.com (\n    alpn=\"h2\"\n    port=443\n    well-known=agent-card.json\n)\n\n; One SVCB record per advertised agent.\nassistant.example.com. 3600 IN SVCB 1 . (\n    alpn=\"mcp,h2\"\n    port=443\n    ipv4hint=192.0.2.1\n    ipv6hint=2001:db8::1\n    well-known=agent-card.json\n    cap=https://example.com/.well-known/agent-card.json\n)",
      "fixLang": "text",
      "fixNote": "TYPE64 is the SVCB record type by number, which also answers on older dig builds where the SVCB mnemonic silently returns nothing; publish on the authoritative nameservers for the apex domain, not on www."
    },
    {
      "id": "canonical",
      "category": "discoverability",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Canonical and hreflang discipline",
      "why": "An agent needs one consistent URL per important page.",
      "skill": "canonical",
      "skillTitle": "Establish canonical and hreflang discipline",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner extracts the link rel=canonical element and every link rel=alternate hreflang element from https://<domain>/ ($home in the probe map). Pass = exactly one absolute, self-referential canonical on https with no tracking parameters, plus, when translations exist, reciprocal hreflang alternates that include an x-default. Partial = a canonical that is relative to another host or points at a different URL, or hreflang values that no other language version links back to. Fail = no canonical element on the page.",
      "verify": "DOMAIN=example.com\ncurl -sS \"https://$DOMAIN/\" | grep -Eio '<link[^>]+rel=\"(canonical|alternate)\"[^>]*>'",
      "fix": "<link rel=\"canonical\" href=\"https://example.com/pricing\">\n<link rel=\"alternate\" hreflang=\"en\" href=\"https://example.com/pricing\">\n<link rel=\"alternate\" hreflang=\"de\" href=\"https://example.com/de/preise\">\n<link rel=\"alternate\" hreflang=\"x-default\" href=\"https://example.com/pricing\">",
      "fixLang": "html",
      "fixNote": "The canonical must be absolute and must repeat the exact URL an agent reached; hreflang only works when every language version lists all the others, including itself."
    },
    {
      "id": "llms-full",
      "category": "discoverability",
      "tier": "recommended",
      "weight": 1,
      "impact": "low",
      "maturity": "draft",
      "conditional": false,
      "label": "/llms-full.txt expanded context",
      "why": "Lets an agent take in the full context in a single request instead of walking the site step by step.",
      "skill": "llms-full-txt",
      "skillTitle": "Publish llms-full.txt",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/llms-full.txt",
        "/llms.txt"
      ],
      "sources": [],
      "measures": "The scanner requests https://<domain>/llms-full.txt and also reads /llms.txt to see whether that file is advertised. Pass = /llms-full.txt returns 200 markdown containing the expanded text of the site's key pages, and /llms.txt links to it. Partial = the URL is linked from /llms.txt but 404s, or the file returns only a list of links instead of page text. Fail = neither file offers expanded context, or both paths return HTML.",
      "verify": "DOMAIN=example.com\ncurl -sS -o /dev/null -w 'llms-full.txt status=%{http_code} bytes=%{size_download}\\n' \"https://$DOMAIN/llms-full.txt\"; curl -sS \"https://$DOMAIN/llms.txt\" | grep -i 'llms-full'",
      "fix": "# llms.txt - add this line to the Docs section\n- [Full context](https://example.com/llms-full.txt): Every page below as one document\n\n# llms-full.txt\n# Example Inc - full context\n\n> Complete text of the pages an agent needs, in a single request. Regenerated on every deploy.\n\n## https://example.com/ (Home)\n\nExample Inc runs a payments API for marketplaces. Founded 2019, 40 staff, US-based. Production traffic is served from three regions.\n\n## https://example.com/docs/quickstart (Quickstart)\n\nInstall the CLI with `npm i -g example-cli`, authenticate with `example login`, then create a charge with `example charges create --amount 1000 --currency usd`.\n\n## https://example.com/pricing (Pricing)\n\nStarter is $0 for 1,000 calls per month. Growth is $99 for 100,000 calls, then $0.001 per call. Enterprise is quoted annually.",
      "fixLang": "markdown",
      "fixNote": "This file is the whole site minus navigation, so cap it at a few hundred KB and generate it from the same source as your pages or it will drift out of date."
    },
    {
      "id": "agents-md",
      "category": "discoverability",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "AGENTS.md with the required sections",
      "why": "The instruction file for coding and operations agents. It fails more often than any other check in the curated corpus we measured, and the usual reason is not a missing file but a file without the sections an agent needs.",
      "skill": "agents-md",
      "skillTitle": "Publish AGENTS.md",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/AGENTS.md"
      ],
      "sources": [],
      "measures": "The scanner fetches https://<domain>/AGENTS.md (the path this check probes) and reads it as markdown. Pass = 200 served as text/markdown or text/plain, with an H1 and the sections an agent needs to act: what the project or service is, setup or build commands, how to run tests, code or API conventions, and security or contact rules. Partial = the file is served but omits one or more of those sections, or is returned as text/html. Fail = 404 or an HTML page for the path.",
      "verify": "DOMAIN=example.com\ncurl -sS \"https://$DOMAIN/AGENTS.md\" | head -30",
      "fix": "# AGENTS.md\n\n## Project overview\n\nExample Inc is a payments API for marketplaces. Public docs: https://example.com/docs\n\n## Setup and build\n\n- Install dependencies: `pnpm install`\n- Dev server: `pnpm dev` (http://localhost:3000)\n- Production build: `pnpm build`\n\n## Testing\n\n- Tests: `pnpm test`; lint and types: `pnpm lint && pnpm typecheck`\n\n## Conventions\n\n- TypeScript strict mode; no `any` in exported signatures.\n- Public API routes live in `src/routes/api/` and must be documented in the OpenAPI file.\n\n## Security and contact\n\n- Never commit secrets; use `.env.local`.\n- Report vulnerabilities to security@example.com.",
      "fixLang": "markdown",
      "fixNote": "AGENTS.md is plain markdown with no mandated fields, so the headings above are the sections this audit looks for - and in a monorepo the file nearest the edited code wins."
    },
    {
      "id": "sitemap-md",
      "category": "discoverability",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "draft",
      "conditional": false,
      "label": "/sitemap.md markdown site map",
      "why": "Lets an agent read your site structure as markdown in a single file. Adoption is still very low, so this is an early-mover advantage.",
      "skill": "sitemap-md",
      "skillTitle": "Publish a markdown sitemap",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/sitemap.md"
      ],
      "sources": [],
      "measures": "The scanner fetches https://<domain>/sitemap.md (the exact path this check probes) and parses it as markdown. Pass = 200 markdown whose body is an H1 plus markdown links to the site's key pages written as absolute URLs. Partial = markdown is served but the links are relative, are not a list, or cover only a couple of top-level pages. Fail = 404, or an HTML document returned for the path.",
      "verify": "DOMAIN=example.com\ncurl -sS \"https://$DOMAIN/sitemap.md\" | head -30",
      "fix": "# Sitemap\n\n- [Home](https://example.com/): What Example Inc does and who it is for\n- [Quickstart](https://example.com/docs/quickstart): First API call in five minutes\n- [API reference](https://example.com/docs/api): Every endpoint and response field\n- [Authentication](https://example.com/docs/auth): OAuth 2.0 flows and token lifetimes\n- [Pricing](https://example.com/pricing): Plans, limits and overage rates\n- [Status](https://example.com/status): Live service status and incident history\n- [Contact](https://example.com/contact): Sales and support routes",
      "fixLang": "markdown",
      "fixNote": "Keep it to the pages an agent should actually read and regenerate it whenever sitemap.xml changes, since the two drifting apart is the usual failure."
    },
    {
      "id": "markdown-negotiation",
      "category": "content",
      "tier": "recommended",
      "weight": 3,
      "impact": "critical",
      "maturity": "adopted",
      "conditional": false,
      "label": "Markdown content negotiation",
      "why": "Agents can request a markup-free version with \"Accept: text/markdown\", which cuts token cost.",
      "skill": "markdown-negotiation",
      "skillTitle": "Serve markdown on Accept: text/markdown",
      "standards": [
        "RFC 9110"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner re-requests the homepage `/` with `Accept: text/markdown` and inspects the response headers and body. Pass when the response carries `Content-Type: text/markdown`, the body is markdown rather than HTML (no `<!doctype html>`, no `<div>` scaffolding) and it is materially shorter than the HTML page; partial when markdown is returned under another type such as `text/plain`, or when the response omits `Vary: Accept` so a shared cache can serve the markdown body to a browser; fail when the request still returns `text/html`. RFC 9110 proactive negotiation, one URL with two representations.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sSL -D /tmp/md.h -o /tmp/md.body -H 'Accept: text/markdown' \"$DOMAIN/\"; grep -iE '^(HTTP/|content-type|vary)' /tmp/md.h; head -3 /tmp/md.body",
      "fix": "// server.js — serve a prebuilt markdown twin on Accept: text/markdown (RFC 9110).\nimport express from 'express';\nimport { readFile } from 'node:fs/promises';\n\nconst app = express();\n\napp.use(async (req, res, next) => {\n  if (req.method !== 'GET') return next();\n  res.vary('Accept'); // both representations share one URL, so caches must key on Accept\n  if (req.accepts(['text/html', 'text/markdown']) !== 'text/markdown') return next();\n  const file = `./public${req.path.replace(/\\/$/, '/index')}.md`;\n  try {\n    res.type('text/markdown').send(await readFile(file, 'utf8'));\n  } catch {\n    next(); // no markdown twin for this path: fall back to the HTML document\n  }\n});\n\napp.use(express.static('public'));\napp.listen(3000);",
      "fixLang": "javascript",
      "fixNote": "Generate the `.md` twin at build time — converting HTML to markdown on every request is slower and lossy — and keep `Vary: Accept` on the HTML response too."
    },
    {
      "id": "headings",
      "category": "content",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "Semantic heading structure",
      "why": "A single H1 and a consistent H2 structure let the content be split correctly.",
      "skill": "semantic-headings",
      "skillTitle": "Build a semantic heading structure",
      "standards": [
        "WCAG 2.2"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner parses the headings in the homepage `/` HTML and rebuilds the outline. Pass when the page has exactly one `<h1>` and no heading is more than one level deeper than the one before it (an `<h2>` may be followed by `<h3>`, never by `<h4>`); partial when there is exactly one `<h1>` but the outline skips a level, or when the page repeats `<h1>` while the rest of the outline is well formed; fail when the page has no `<h1>` at all. This is the WCAG 2.2 heading structure (1.3.1 Info and Relationships, 2.4.6 Headings and Labels).",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{<(h[1-6])\\b[^>]*>(.*?)</\\1>}gsi) { my ($l,$t)=(lc($1),$2); $t=~s/<[^>]*>//g; $t=~s/\\s+/ /g; print \"$l \",substr($t,0,60),\"\\n\" }'",
      "fix": "<main>\n  <h1>Agent readiness for e-commerce teams</h1>\n  <p class=\"summary\">A 5-dimension audit of how well your store serves autonomous AI agents.</p>\n\n  <section>\n    <h2>What we measure</h2>\n    <p>Discovery, content, bot access, protocol and commerce signals.</p>\n\n    <section>\n      <h3>Structured data</h3>\n      <p>Entity types an agent can quote without having to guess.</p>\n    </section>\n  </section>\n\n  <section>\n    <h2>How the score works</h2>\n    <p>61 weighted checks, graded from C to AAA.</p>\n  </section>\n</main>",
      "fixLang": "html",
      "fixNote": "Never pick a heading tag for its font size: if a block has no heading, style a `<p>` instead and keep `<h1>` for the page title only."
    },
    {
      "id": "meta",
      "category": "content",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "Title and summary metadata",
      "why": "This is how an agent learns what a page is about without opening it.",
      "skill": "page-metadata",
      "skillTitle": "Add a title and meta description",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner reads the `<title>` text and the `content` of `<meta name=\"description\">` in the homepage `/` HTML and measures their length. Pass when the title is 50-60 characters and the description 120-160 characters, both non-empty; partial when both are present but one or both fall outside that window (too short to be informative, or long enough to be truncated), or when only one of the two exists; fail when the page has neither a title nor a description.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'm{<title[^>]*>(.*?)</title>}si and do { my $t=$1; $t=~s/\\s+/ /g; print \"title(\", length($t), \"): $t\\n\" }; m{<meta[^>]*\\bname=[\"\\x27]?description[\"\\x27]?[^>]*>}i and print \"description: \", $&, \"\\n\"'",
      "fix": "<head>\n  <meta charset=\"utf-8\" />\n  <title>Agent readiness audit for e-commerce teams | Acme Inc</title>\n  <meta\n    name=\"description\"\n    content=\"Acme audits how well your store serves AI agents: 61 checks across discovery, content, bot access and commerce, each one paired with a copy-pasteable fix.\"\n  />\n</head>",
      "fixLang": "html",
      "fixNote": "Write a distinct description per page: one templated string reused across 500 URLs tells an agent nothing about which page it is reading."
    },
    {
      "id": "jsonld",
      "category": "content",
      "tier": "essential",
      "weight": 3,
      "impact": "critical",
      "maturity": "adopted",
      "conditional": false,
      "label": "JSON-LD structured data",
      "why": "The entity type becomes unambiguous to a machine. Without authoritative data, an agent answers from whatever it can scrape.",
      "skill": "structured-data",
      "skillTitle": "Add JSON-LD structured data",
      "standards": [
        "schema.org"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner extracts every `<script type=\"application/ld+json\">` block from the homepage `/`, parses it as JSON and walks the graph, including members of `@graph`. Pass when at least one block is valid JSON and declares `@context` `https://schema.org` plus an `@type` that names the page entity (`WebSite`, `Organization`, `Article`); partial when a block exists but is invalid JSON, or parses yet has no `@context` or no `@type`; fail when the page has no JSON-LD block at all.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '[(.\"@context\"? // empty | tostring)] + [.. | objects | select(has(\"@type\")) | .\"@type\" | tostring] | unique | @tsv'",
      "fix": "<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"WebSite\",\n  \"name\": \"Acme Agent Readiness\",\n  \"url\": \"https://www.example.com/\",\n  \"description\": \"Audits how well a website serves autonomous AI agents.\",\n  \"publisher\": {\n    \"@type\": \"Organization\",\n    \"name\": \"Acme\",\n    \"url\": \"https://www.example.com/\",\n    \"logo\": \"https://www.example.com/logo.svg\"\n  }\n}\n</script>",
      "fixLang": "html",
      "fixNote": "One valid block beats five broken ones: JSON-LD is parsed, not scraped, so a single trailing comma makes the whole entity invisible."
    },
    {
      "id": "faq-schema",
      "category": "content",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "FAQ / question-answer schema",
      "why": "Question-and-answer content is the type agents quote most.",
      "skill": "faq-schema",
      "skillTitle": "Add FAQ schema",
      "standards": [
        "schema.org/FAQPage"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "This check declares no probe path, so the scanner works from the JSON-LD of the already fetched homepage `/` and looks for a `FAQPage` entity anywhere in the graph. Pass when `FAQPage` is present and `mainEntity` is a non-empty array of objects typed `Question` that each carry a `name` and an `acceptedAnswer` typed `Answer` with non-empty `text`; partial when `FAQPage` exists but `mainEntity` is empty, when an entry is not typed `Question`, or when a question lacks a typed, non-empty `acceptedAnswer`; fail when no `FAQPage` type appears.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select((.\"@type\"? | if type == \"array\" then .[] else . end) == \"FAQPage\") | .mainEntity[] | [.name, (.acceptedAnswer.text // \"NO ANSWER\")] | @tsv'",
      "fix": "<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What does an agent readiness audit measure?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"61 checks across discovery, content, bot access, protocol and commerce, each scored and paired with a fix.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How long does the audit take?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Scanning your public pages takes about two minutes; the scored report is generated immediately.\"\n      }\n    }\n  ]\n}\n</script>",
      "fixLang": "html",
      "fixNote": "The same questions must be visible as text on the page — structured data that does not match rendered content is treated as spam."
    },
    {
      "id": "breadcrumb",
      "category": "content",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "draft",
      "conditional": false,
      "label": "Breadcrumb / navigation schema",
      "why": "Helps an agent make sense of where a page sits in your site.",
      "skill": "breadcrumb-schema",
      "skillTitle": "Add breadcrumb schema",
      "standards": [
        "schema.org/BreadcrumbList"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "This check declares no probe path, so the scanner walks the JSON-LD of the fetched homepage `/` for a `BreadcrumbList` and inspects `itemListElement`. Pass when the list has at least two entries typed `ListItem` whose `position` values start at 1 and increase by one, each with a `name` and an `item` that is a resolvable URL (an absolute http(s) URL or a host-relative path — a bare string such as `garbage` is not an item URL). The last entry may omit `item` entirely, as Google's breadcrumb guidance allows, because it stands for the current page. Partial when `BreadcrumbList` is present but an entry is not typed `ListItem`, a position is missing or out of order, an entry before the last has no resolvable `item` URL, or any entry carries an `item` that is not one; fail when no `BreadcrumbList` appears.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select((.\"@type\"? | if type == \"array\" then .[] else . end) == \"BreadcrumbList\") | .itemListElement[] | [(.position|tostring), .name, ((.item.\"@id\" // .item) // \"NO URL\")] | @tsv'",
      "fix": "<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"BreadcrumbList\",\n  \"itemListElement\": [\n    { \"@type\": \"ListItem\", \"position\": 1, \"name\": \"Home\", \"item\": \"https://www.example.com/\" },\n    { \"@type\": \"ListItem\", \"position\": 2, \"name\": \"Checks\", \"item\": \"https://www.example.com/checks\" },\n    { \"@type\": \"ListItem\", \"position\": 3, \"name\": \"Structured data\", \"item\": \"https://www.example.com/checks/jsonld\" }\n  ]\n}\n</script>",
      "fixLang": "html",
      "fixNote": "Mirror the same trail as visible links in a `<nav aria-label=\"Breadcrumb\">` — schema alone helps no reader."
    },
    {
      "id": "summary",
      "category": "content",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Prominent summary block",
      "why": "The llms.txt contract recommends a short summary at the top of every page; agents quote it directly.",
      "skill": "page-summary",
      "skillTitle": "Add a summary block",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner strips `<script>`, `<style>` and `<noscript>` from the homepage `/`, takes the main content region (`<main>`, otherwise the body) and reads its first 1000 characters. Pass when a 40-320 character paragraph appears there before the first `<h2>` — an element whose `class` or `id` contains `summary`, `lede` or `intro` also counts; partial when a summary exists but starts after the first `<h2>` or falls outside 40-320 characters; fail when the first 1000 characters of main content hold no such paragraph. The llms.txt contract asks for this short summary at the top of every page.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 's{<(script|style|noscript)[^>]*>.*?</\\1>}{}gsi; m{<main[^>]*>(.*?)</main>}si and $_=$1; s/<[^>]*>/ /g; s/\\s+/ /g; print substr($_,0,260),\"\\n\"'",
      "fix": "<main>\n  <h1>Token efficiency for agent-readable pages</h1>\n  <p class=\"summary\">\n    Content-to-markup ratio is the share of a page's bytes an agent can actually read: below 15%\n    most of your HTML is scaffolding, and every wrapper div is billed to someone's context window.\n  </p>\n  <h2>How the ratio is measured</h2>\n  <p>We strip script, style and template blocks, remove the remaining tags and divide the visible\n  characters by the raw HTML size.</p>\n</main>",
      "fixLang": "html",
      "fixNote": "Keep it to two or three sentences: the block is quoted verbatim, so \"Welcome to our website\" spends the quote on nothing."
    },
    {
      "id": "lang",
      "category": "content",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Language declaration and multilingual access",
      "why": "A wrong language tag makes an agent misread the content.",
      "skill": "language-declaration",
      "skillTitle": "Add a language declaration and hreflang",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner reads the `lang` attribute of the root `<html>` element on the homepage `/` and, when present, every `<link rel=\"alternate\" hreflang=\"...\">` value. Pass when `lang` is a well-formed language tag (`en` or `en-GB`, primary subtag matching the visible language) and each `hreflang` is a tag whose `href` is an absolute URL that returns that language; partial when `lang` is empty or malformed, or when alternates exist without a self-referencing entry and an `x-default`; fail when the page declares no language at all and has no `<meta http-equiv=\"content-language\">` fallback.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'm{<html[^>]*\\blang=[\"\\x27]?([^\"\\x27\\s>]+)}i and print \"html lang=$1\\n\"; while (m{<link[^>]*\\bhreflang=[\"\\x27]([^\"\\x27]+)[\"\\x27][^>]*>}gi) { my ($hl,$h)=($1,undef); ($h) = $& =~ /\\bhref=[\"\\x27]([^\"\\x27]+)/i; print \"hreflang=$hl href=\", (defined $h ? $h : \"MISSING\"), \"\\n\" }'",
      "fix": "<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"alternate\" hreflang=\"en\" href=\"https://www.example.com/\" />\n    <link rel=\"alternate\" hreflang=\"de\" href=\"https://www.example.com/de/\" />\n    <link rel=\"alternate\" hreflang=\"x-default\" href=\"https://www.example.com/\" />\n  </head>\n  <body>\n    <!-- one language per URL: never mix translations on the same page -->\n  </body>\n</html>",
      "fixLang": "html",
      "fixNote": "Tags are case-insensitive but use a hyphen, never an underscore (`en-GB`, not `en_GB`), and every `hreflang` href must be absolute and actually resolve."
    },
    {
      "id": "no-js-fallback",
      "category": "content",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "Content that needs no JavaScript",
      "why": "If content is only produced client-side, the page is empty for agents that do not run JavaScript.",
      "skill": "server-rendering",
      "skillTitle": "Serve content that needs no JavaScript",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner fetches the homepage `/` without executing JavaScript, removes `<script>`, `<style>`, `<noscript>` and `<template>` blocks and measures the visible text that remains. Pass when the static HTML holds at least 400 characters of text plus an `<h1>`; partial when 100-399 characters remain, or when all the copy sits inside `<noscript>`; fail below 100 characters, the signature of `<div id=\"root\"></div>` filled entirely on the client.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -pe 's{<(script|style|noscript|template)[^>]*>.*?</\\1>}{}gsi; s/<[^>]*>/ /g; s/\\s+/ /g' | wc -m",
      "fix": "import express from 'express';\nconst app = express();\n\nconst pages = {\n  '/': { title: 'Agent readiness audit', body: '<p>We measure how well your site serves AI agents.</p>' },\n  '/pricing': { title: 'Pricing', body: '<p>Audits start at $490 per site.</p>' },\n};\n\n// The first byte already contains the content: no JavaScript is needed to read the page.\napp.use((req, res) => {\n  const page = pages[req.path] ?? { title: 'Not found', body: '<p>Try the homepage.</p>' };\n  res.status(pages[req.path] ? 200 : 404).type('html').send(`<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>${page.title}</title></head>\n<body><main><h1>${page.title}</h1>${page.body}</main>\n<script type=\"module\" src=\"/app.js\"></script></body></html>`);\n});\n\napp.listen(3000);",
      "fixLang": "javascript",
      "fixNote": "Escape any user data before interpolating it, and keep the client bundle an enhancement: the text has to be in the HTML before the script runs."
    },
    {
      "id": "spa-fallback",
      "category": "content",
      "tier": "emerging",
      "weight": 2,
      "impact": "medium",
      "maturity": "experimental",
      "conditional": false,
      "label": "Per-page content separation (no SPA fallback)",
      "why": "A catch-all route returning the same skeleton for every URL makes pages indistinguishable to an agent.",
      "skill": "server-rendering",
      "skillTitle": "Apply server-side rendering",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "This check declares no probe path; the scanner compares the homepage `/` with a second same-origin URL. Pass when the two responses have different `<title>` values and different main text (each route ships its own document); partial when the titles differ but the main content region is identical, or when an unknown path answers 200 with the app shell instead of 404; fail when two different URLs return the same title and near-identical markup — the catch-all rewrite to one `index.html`.",
      "verify": "DOMAIN=\"https://example.com\"\nfor p in / /about; do printf '%s\\t' \"$p\"; curl -sL -o /tmp/page.html -w '%{http_code}\\t' \"$DOMAIN$p\"; perl -0777 -ne 'm{<title[^>]*>(.*?)</title>}si and do { my $t=$1; $t=~s/\\s+/ /g; print \"title=$t\\t\" }; s{<(script|style)[^>]*>.*?</\\1>}{}gsi; m{<main[^>]*>(.*?)</main>}si and $_=$1; s/<[^>]*>//g; s/\\s+/ /g; print \"text=\", length($_), \"\\n\"' /tmp/page.html; done",
      "fix": "// Serve dist/<route>/index.html per URL; never rewrite every path to one shell.\nimport express from 'express';\nimport { existsSync } from 'node:fs';\n\nconst app = express();\n\napp.use((req, res) => {\n  if (req.method !== 'GET') return res.status(405).end();\n  const file = new URL(`dist${req.path.replace(/\\/$/, '/index')}.html`, import.meta.url).pathname;\n  if (existsSync(file)) return res.sendFile(file);\n  res.status(404).type('html')\n    .send('<!doctype html><html lang=\"en\"><title>404</title><h1>Not found</h1></html>');\n});\n\napp.listen(3000);",
      "fixLang": "javascript",
      "fixNote": "Prerender each route at build time or render it on the server, and return a real 404 for unknown paths — delete the `/* -> /index.html` rewrite."
    },
    {
      "id": "token-efficiency",
      "category": "content",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Token efficiency (content-to-markup ratio)",
      "why": "A bloated DOM wastes an agent's context window.",
      "skill": "token-efficiency",
      "skillTitle": "Improve the content-to-markup ratio",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner strips `<script>`, `<style>`, `<svg>` and `<template>` blocks from the homepage `/`, removes the remaining tags, normalises whitespace and divides visible characters by the raw HTML size. Pass when text is at least 15% of the page; partial between 8% and 14.9%; fail below 8%, where the markup an agent must read outweighs the content.",
      "verify": "DOMAIN=\"https://example.com\"\nhtml=$(curl -sL \"$DOMAIN/\"); text=$(printf '%s' \"$html\" | perl -0777 -pe 's{<(script|style|svg|template|noscript)[^>]*>.*?</\\1>}{}gsi; s/<[^>]*>/ /g; s/\\s+/ /g'); printf 'text=%d chars, html=%d chars, ratio=%d%%\\n' \"${#text}\" \"${#html}\" \"$(( ${#text} * 100 / ${#html} ))\"",
      "fix": "// scripts/tighten-html.mjs — run after the build over every emitted page.\nimport { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { minify } from 'html-minifier-terser';\n\nconst walk = (dir) => readdirSync(dir).flatMap((name) => {\n  const p = join(dir, name);\n  return statSync(p).isDirectory() ? walk(p) : p.endsWith('.html') ? [p] : [];\n});\n\nfor (const file of walk('dist')) {\n  const before = readFileSync(file, 'utf8');\n  const after = await minify(before, {\n    collapseWhitespace: true,\n    removeComments: true,\n    removeRedundantAttributes: true,\n    minifyCSS: true,\n    minifyJS: true,\n  });\n  writeFileSync(file, after);\n  console.log(`${file}: ${before.length} -> ${after.length} bytes`);\n}",
      "fixLang": "javascript",
      "fixNote": "Minification recovers 10-20% of bytes; the real gain is deleting wrapper divs, inline SVG and unused framework markup, so measure before and after with the same command."
    },
    {
      "id": "viewport",
      "category": "content",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "adopted",
      "conditional": false,
      "label": "Responsive markup",
      "why": "Agent browsers mostly run at mobile scale.",
      "skill": "responsive-markup",
      "skillTitle": "Add responsive markup",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "The scanner reads the `<meta name=\"viewport\">` tag of the homepage `/`. Pass when its `content` contains `width=device-width` and does not restrict zoom (no `maximum-scale=1`, no `user-scalable=no`); partial when the tag exists but omits `width=device-width`, or pins `maximum-scale` or `user-scalable=no`; fail when no viewport tag is present, so an agent browser at mobile scale renders the desktop layout zoomed out.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'm{<meta[^>]*\\bname=[\"\\x27]?viewport[\"\\x27]?[^>]*>}i and print \"viewport: \", $&, \"\\n\"; while (m/\\@media[^{]*max-width[^{]*/gi) { print \"media: \", $&, \"\\n\" }' | head -4",
      "fix": "<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <style>\n    img, video { max-width: 100%; height: auto; }\n    .grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }\n    @media (max-width: 640px) {\n      .sidebar { display: none; }\n      h1 { font-size: 1.75rem; }\n    }\n  </style>\n</head>",
      "fixLang": "html",
      "fixNote": "Never add `maximum-scale=1` or `user-scalable=no`: pinning the scale scores as a partial even when the rest of the tag is correct."
    },
    {
      "id": "webbotauth",
      "category": "botaccess",
      "tier": "essential",
      "weight": 3,
      "impact": "critical",
      "maturity": "draft",
      "conditional": false,
      "label": "Web Bot Auth key directory",
      "why": "RFC 9421 with Ed25519: the mechanism that lets a site verify which agent is calling rather than guessing from a user-agent string. Adoption in the curated corpus we measured is around one site in a hundred.",
      "skill": "web-bot-auth",
      "skillTitle": "Publish a Web Bot Auth key directory",
      "standards": [
        "RFC 9421"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/http-message-signatures-directory"
      ],
      "sources": [],
      "measures": "The scanner issues GET https://<domain>/.well-known/http-message-signatures-directory and expects HTTP 200 with a JWK Set: a top-level keys array in which at least one entry is a usable Ed25519 verification key (kty \"OKP\", crv \"Ed25519\", a base64url 32-byte x, and a kid) that RFC 9421 Signature and Signature-Input verification can resolve. It passes when such a key is published, returns partial when the endpoint answers 200 with parseable JSON but no usable Ed25519 key (empty keys array, missing x, wrong crv, or an RSA-only set), and fails on a non-200, an HTML body or unparseable JSON.",
      "verify": "DOMAIN=\"example.com\"\ncurl -sSL -o /tmp/wba.json -w 'HTTP %{http_code} %{content_type}\\n' \"https://$DOMAIN/.well-known/http-message-signatures-directory\"; grep -Eo '\"(kid|kty|crv|x)\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' /tmp/wba.json | sort -u",
      "fix": "{\n  \"keys\": [\n    {\n      \"kty\": \"OKP\",\n      \"crv\": \"Ed25519\",\n      \"x\": \"K6zqFKAe5fHfdzecJiq7nnGe0teq-_bhC1eP_fGo5bc\",\n      \"kid\": \"2026-10-primary\",\n      \"use\": \"sig\",\n      \"alg\": \"Ed25519\"\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "Serve this file over HTTPS at /.well-known/http-message-signatures-directory (application/json is fine; deployments in the wild also use application/http-message-signatures-directory+json), and replace x with the base64url of your own Ed25519 public key (openssl genpkey -algorithm ED25519) so that the private key stays in your signer."
    },
    {
      "id": "agent-permissions",
      "category": "botaccess",
      "tier": "essential",
      "weight": 3,
      "impact": "critical",
      "maturity": "experimental",
      "conditional": false,
      "label": "Agent permission model (agent-permissions.json)",
      "why": "Declares which actions an agent may take, which are refused, and which need a person. It is the difference between a policy and a wish: in the curated corpus we measured, roughly one site in a hundred publishes one.",
      "skill": "agent-permissions",
      "skillTitle": "Publish an agent permission model",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/agent-permissions.json",
        "/agent-permissions.json"
      ],
      "sources": [],
      "measures": "The scanner requests /.well-known/agent-permissions.json and falls back to /agent-permissions.json, expecting HTTP 200 with a JSON object that maps named actions to a permission decision. It passes when the document declares a default plus at least one allow action and at least one human-approval action, returns partial when it parses and names actions but has no human-approval rule or is only a flat list of action names, and fails when both paths answer non-200 or return a body that is not JSON.",
      "verify": "DOMAIN=\"example.com\"\nfor P in /.well-known/agent-permissions.json /agent-permissions.json; do echo \"$P -> $(curl -sS -o /dev/null -w '%{http_code} %{content_type} %{redirect_url}' \"https://$DOMAIN$P\")\"; done; curl -sS \"https://$DOMAIN/.well-known/agent-permissions.json\" | grep -Eo '\"(allow|deny|human-approval)\"' | sort | uniq -c",
      "fix": "{\n  \"version\": \"1.0\",\n  \"site\": \"https://example.com\",\n  \"updated\": \"2026-10-08\",\n  \"default\": \"deny\",\n  \"actions\": {\n    \"read:public-content\": { \"permission\": \"allow\" },\n    \"read:pricing\": { \"permission\": \"allow\" },\n    \"train:model\": { \"permission\": \"deny\" },\n    \"scrape:bulk\": { \"permission\": \"deny\" },\n    \"purchase:create-cart\": { \"permission\": \"allow\" },\n    \"purchase:pay\": { \"permission\": \"human-approval\", \"note\": \"A human confirms payment before capture.\" },\n    \"account:change-credentials\": { \"permission\": \"deny\" }\n  },\n  \"contact\": \"agents@example.com\",\n  \"policy\": \"https://example.com/ai-policy\"\n}",
      "fixLang": "json",
      "fixNote": "No IETF standard defines this filename yet, so the scanner reads exactly the vocabulary this rubric measures: a default plus per-action permission values of allow, deny or human-approval."
    },
    {
      "id": "ai-rules",
      "category": "botaccess",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "Named AI bot rules in robots.txt",
      "why": "Naming agent crawlers explicitly is the first step to being visible in agent traffic.",
      "skill": "ai-rules",
      "skillTitle": "Add named AI bot rules to robots.txt",
      "standards": [
        "RFC 9309"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$robots"
      ],
      "measures": "The scanner reads the already-fetched /robots.txt and looks for groups whose user-agent line names an AI crawler explicitly (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-User, PerplexityBot, Google-Extended, CCBot, Bytespider, Applebot-Extended, Meta-ExternalAgent) with Allow or Disallow rules of their own. It passes with at least one such named group carrying a rule, returns partial when robots.txt exists but AI names appear only in comments or the only rules sit in the User-agent: * group, and fails when /robots.txt is missing, empty or HTML.",
      "verify": "DOMAIN=\"example.com\"\ncurl -sSL \"https://$DOMAIN/robots.txt\" | grep -Ei 'user-agent:[[:space:]]*(gptbot|oai-searchbot|chatgpt-user|claudebot|claude-user|claude-searchbot|google-extended|perplexitybot|perplexity-user|ccbot|bytespider|applebot-extended|meta-externalagent)' | sort -u",
      "fix": "# robots.txt - https://example.com/robots.txt\n# Answer engines are welcome and named explicitly.\nUser-agent: OAI-SearchBot\nAllow: /\nUser-agent: ChatGPT-User\nAllow: /\nUser-agent: Claude-User\nAllow: /\nUser-agent: PerplexityBot\nAllow: /\n\n# Training crawlers are named and refused.\nUser-agent: GPTBot\nDisallow: /\nUser-agent: ClaudeBot\nDisallow: /\nUser-agent: CCBot\nDisallow: /\n\nUser-agent: *\nAllow: /\nDisallow: /admin/\n\nSitemap: https://example.com/sitemap.xml",
      "fixLang": "text",
      "fixNote": "Under RFC 9309 a crawler obeys the group whose product token matches it and only falls back to User-agent: * when no named group matches, so repeat inside each named block every Allow or Disallow you want that crawler to see."
    },
    {
      "id": "content-signal",
      "category": "discoverability",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "Content-Signal usage signals",
      "why": "Declares how your content may be used, inside robots.txt. Cloudflare included this in its readiness checks.",
      "skill": "content-signals",
      "skillTitle": "Declare Content-Signal usage preferences",
      "standards": [
        "contentsignals.org"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$robots",
        "$home"
      ],
      "measures": "The scanner parses https://<domain>/robots.txt ($robots in the probe map) for a Content-Signal directive inside a user-agent group, per the Content Signals Policy (contentsignals.org). Pass = at least one Content-Signal line that sets all three signals - search, ai-input and ai-train - to yes or no. Partial = the directive appears but omits one or more of the three signals, or sits outside any user-agent group. Fail = no Content-Signal line in robots.txt.",
      "verify": "DOMAIN=example.com\ncurl -sS \"https://$DOMAIN/robots.txt\" | grep -i -B1 '^content-signal:'",
      "fix": "# Content Signals Policy for example.com\n#\n# search   = building a search index and returning links and short excerpts\n# ai-input = feeding content into a model at answer time (RAG, grounding)\n# ai-train = training or fine-tuning a model\n# yes grants the use, no withholds it; an omitted signal does neither.\n\nUser-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=no\nAllow: /\n\n# Per-crawler preferences go inside that crawler's own group.\nUser-agent: GPTBot\nContent-Signal: search=yes, ai-input=yes, ai-train=no\nAllow: /",
      "fixLang": "text",
      "fixNote": "The directive is only read inside a user-agent group, values are case-insensitive yes or no, and omitting a signal neither grants nor restricts that use."
    },
    {
      "id": "ai-policy",
      "category": "botaccess",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "AI usage policy page",
      "why": "States the rules agents must follow and reduces licensing ambiguity.",
      "skill": "ai-policy",
      "skillTitle": "Publish an AI usage policy",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "This check has no fixed probe path, so the scanner fetches the home page, collects its anchors and matches href or anchor text against AI-policy patterns (/ai-policy, /ai-usage-policy, /ai, /policies/ai, \"AI usage policy\") before fetching the match. It passes when a linked page states at least one permitted and one prohibited use of the content plus a contact route, returns partial when the page exists but is unlinked or carries no explicit permitted and prohibited statements, and fails when no policy page can be found.",
      "verify": "DOMAIN=\"example.com\"\nLINK=$(curl -sS \"https://$DOMAIN/\" | grep -Eio 'href=\"[^\"]*(ai|artificial)[^\"]*polic[^\"]*\"' | head -1 | cut -d'\"' -f2); LINK=${LINK#https://$DOMAIN}; curl -sSL \"https://$DOMAIN${LINK:-/ai-policy}\" -o /tmp/ai-policy.html -w 'HTTP %{http_code} %{content_type}\\n'; grep -Eio 'training|crawl|scrap|licen[cs]e|commercial|contact' /tmp/ai-policy.html | sort -u",
      "fix": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>AI usage policy</title>\n  <link rel=\"canonical\" href=\"https://example.com/ai-policy\">\n</head>\n<body>\n  <h1>AI usage policy</h1>\n  <p>Effective 2026-10-08. These rules govern automated access to example.com.</p>\n  <h2>Permitted</h2>\n  <ul>\n    <li>Indexing, retrieval and citation by identified search and answer engines.</li>\n    <li>Summarising public pages with attribution and a link to the source.</li>\n  </ul>\n  <h2>Not permitted</h2>\n  <ul>\n    <li>Training, fine-tuning or distilling a model on our content.</li>\n    <li>Bulk scraping, resale or republishing content as a substitute for the original.</li>\n  </ul>\n  <h2>Contact</h2>\n  <p>Licensing and corrections: <a href=\"mailto:agents@example.com\">agents@example.com</a></p>\n</body>\n</html>",
      "fixLang": "html",
      "fixNote": "Because this check has no fixed probe path, the page must be linked from the home page — add a footer link such as <a href=\"/ai-policy\">AI usage policy</a>, otherwise the scanner never sees it."
    },
    {
      "id": "bot-page",
      "category": "botaccess",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Crawler and agent policy page",
      "why": "Explains which agents are permitted and how they are verified.",
      "skill": "bot-policy",
      "skillTitle": "Publish a crawler and agent policy page",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "With no fixed probe path, the scanner lists the home page's anchors, matches href or anchor text against bot-policy patterns (/bot, /bots, /bot-policy, /agent-policy, /agents, \"for agents\") and reads the page it finds. It passes when that page names the agents it welcomes and refuses and explains how a claimed identity is verified (signed requests checked against a published Web Bot Auth key directory, or published IP ranges) with a contact, returns partial when it lists agents but never explains verification, and fails when no such page is linked or reachable.",
      "verify": "DOMAIN=\"example.com\"\nLINK=$(curl -sS \"https://$DOMAIN/\" | grep -Eio 'href=\"[^\"]*(bot|agent|crawler)[^\"]*\"' | head -1 | cut -d'\"' -f2); LINK=${LINK#https://$DOMAIN}; curl -sSL \"https://$DOMAIN${LINK:-/bot}\" -o /tmp/bot-page.html -w 'HTTP %{http_code} %{content_type}\\n'; grep -Eio 'verified|signature|key directory|user-agent|ip range|allow|deny|contact' /tmp/bot-page.html | sort -u",
      "fix": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>Crawler and agent policy</title>\n</head>\n<body>\n  <h1>Bot and agent policy</h1>\n  <h2>How we verify identity</h2>\n  <p>Signed requests are checked against our key directory at <a href=\"/.well-known/http-message-signatures-directory\">/.well-known/http-message-signatures-directory</a>; unsigned clients are identified by User-Agent and published IP ranges only.</p>\n  <h2>Welcome</h2>\n  <ul>\n    <li>Search and answer engines that identify themselves and cite the source.</li>\n    <li>Agents acting for a person on a specific task, at 1 request per second.</li>\n  </ul>\n  <h2>Refused</h2>\n  <ul>\n    <li>Training and dataset crawlers; see the AI usage policy at /ai-policy.</li>\n    <li>Clients that ignore robots.txt, spoof a User-Agent or exceed the stated rate.</li>\n  </ul>\n  <h2>Contact</h2>\n  <p><a href=\"mailto:agents@example.com\">agents@example.com</a></p>\n</body>\n</html>",
      "fixLang": "html",
      "fixNote": "The scanner finds this page by following a link from your home page, so publish it at /bot and link it (a footer link is enough) — an unlinked page is scored as missing."
    },
    {
      "id": "security-txt",
      "category": "botaccess",
      "tier": "recommended",
      "weight": 1,
      "impact": "low",
      "maturity": "adopted",
      "conditional": false,
      "label": "security.txt (RFC 9116)",
      "why": "Provides a responsible disclosure channel for agent-related security incidents.",
      "skill": "security-txt",
      "skillTitle": "Publish security.txt",
      "standards": [
        "RFC 9116"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/security.txt",
        "/security.txt"
      ],
      "sources": [],
      "measures": "The scanner requests /.well-known/security.txt, falls back to /security.txt while following redirects, and expects HTTP 200 with a text/plain body as RFC 9116 requires. It passes when the file carries a Contact field and an Expires field whose RFC 3339 timestamp is in the future, returns partial when one of those fields is missing or Expires is malformed or already past (the file is then stale), and fails when both paths answer non-200 or return HTML.",
      "verify": "DOMAIN=\"example.com\"\nfor P in /.well-known/security.txt /security.txt; do echo \"$P -> $(curl -sS -o /dev/null -w '%{http_code} %{content_type} %{redirect_url}' \"https://$DOMAIN$P\")\"; done; curl -sSL \"https://$DOMAIN/.well-known/security.txt\" | grep -Ei '^(contact|expires|canonical|policy|preferred-languages):'",
      "fix": "# security.txt - RFC 9116\nContact: mailto:security@example.com\nExpires: 2027-09-30T18:37:07Z\nCanonical: https://example.com/.well-known/security.txt\nPreferred-Languages: en\nPolicy: https://example.com/security-policy\nAcknowledgments: https://example.com/hall-of-fame",
      "fixLang": "text",
      "fixNote": "Contact and Expires are the only mandatory fields and an Expires value in the past makes the whole file stale, so keep the date less than a year out; serve it as text/plain over HTTPS and redirect /security.txt to the /.well-known/ path."
    },
    {
      "id": "crawl-delay",
      "category": "botaccess",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "adopted",
      "conditional": false,
      "label": "Crawl rate declaration",
      "why": "Cloudflare's Verified Bots policy drops AI crawlers that ignore crawl-delay.",
      "skill": "crawl-delay",
      "skillTitle": "Declare a crawl rate",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [
        "$robots"
      ],
      "measures": "The scanner reads /robots.txt (there is no separate probe path) and looks for a Crawl-delay directive together with the user-agent group it belongs to. It passes when at least one Crawl-delay line holds a positive number of seconds inside a group that names an AI crawler or is the wildcard *, returns partial when Crawl-delay appears only under a group for a non-AI agent or the value is zero or non-numeric, and fails when /robots.txt is missing or contains no Crawl-delay line.",
      "verify": "DOMAIN=\"example.com\"\ncurl -sSL \"https://$DOMAIN/robots.txt\" | grep -in -B3 'crawl-delay'",
      "fix": "# robots.txt - https://example.com/robots.txt\nUser-agent: *\nAllow: /\nDisallow: /admin/\nCrawl-delay: 1\n\n# Answer engines: one request per second.\nUser-agent: OAI-SearchBot\nAllow: /\nCrawl-delay: 1\nUser-agent: PerplexityBot\nAllow: /\nCrawl-delay: 1\n\n# Training crawlers: reachable but throttled hard.\nUser-agent: GPTBot\nDisallow: /admin/\nCrawl-delay: 10\nUser-agent: ClaudeBot\nDisallow: /admin/\nCrawl-delay: 10\n\nSitemap: https://example.com/sitemap.xml",
      "fixLang": "text",
      "fixNote": "Crawl-delay is a widely supported extension rather than part of the standard robots.txt grammar and its value is in seconds, and because a crawler obeys the group that matches its own user-agent, the directive must be repeated in every named group where you want it enforced."
    },
    {
      "id": "ai-txt",
      "category": "botaccess",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "draft",
      "conditional": false,
      "label": "/ai.txt access declaration",
      "why": "Some agent ecosystems read an /ai.txt contract alongside robots.txt.",
      "skill": "ai-txt",
      "skillTitle": "Publish an /ai.txt access declaration",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/ai.txt"
      ],
      "sources": [],
      "measures": "The scanner requests /ai.txt and expects HTTP 200 with a text/plain body, not HTML, that reads as an access declaration for automated clients. It passes when the file states both what may be done with the content and what may not, with a contact route, returns partial when the file exists but is HTML, is served with the wrong content type, or is a one-line notice with no usage terms or contact, and fails on a non-200.",
      "verify": "DOMAIN=\"example.com\"\ncurl -sSL -o /tmp/ai.txt -w 'HTTP %{http_code} %{content_type}\\n' \"https://$DOMAIN/ai.txt\"; grep -Ein 'allow|disallow|permit|prohibit|train|contact' /tmp/ai.txt | head -20",
      "fix": "# ai.txt - access declaration for example.com\n# Effective 2026-10-08. Read alongside robots.txt; robots.txt governs crawling.\n\nSite: https://example.com\nContact: agents@example.com\nPolicy: https://example.com/ai-policy\n\nAllow:\n- Indexing, retrieval and citation by identified search and answer engines.\n- Summarising public pages with attribution and a link to the source.\n\nDisallow:\n- Training, fine-tuning or distilling a model on this content.\n- Bulk scraping, resale or redistribution of content or data.\n- Account changes or purchases without human approval.\n\nRate: 1 request per second, descriptive User-Agent, honour robots.txt.",
      "fixLang": "text",
      "fixNote": "/ai.txt is a de-facto convention read alongside robots.txt rather than a registered standard, so keep it as plain text UTF-8 and keep robots.txt as the authoritative rule set."
    },
    {
      "id": "mcp-card",
      "category": "protocol",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "MCP server card",
      "why": "Since MCP 2026-07-28, agents discover tool servers through a published declaration.",
      "skill": "mcp-server-card",
      "skillTitle": "Publish an MCP server card",
      "standards": [
        "MCP 2026-07-28"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/mcp/server-card.json",
        "/.well-known/mcp.json",
        "/mcp.json"
      ],
      "sources": [],
      "measures": "The scanner GETs /.well-known/mcp/server-card.json first, then /.well-known/mcp.json and /mcp.json, and validates the first 200 response as JSON. It then requests the remote URLs of the first card that offers a transport, on the scanned origin only and up to a published cap, with one plain GET and no credentials. It passes when the document is a usable MCP Server Card - a reverse-DNS name containing exactly one slash, a version, a description, and at least one usable remotes entry giving a transport type (streamable-http or sse), a url and the supported protocol versions - and at least one remote on the scanned origin answered. A remote answers with a 2xx; a streamable-http remote also answers with 405 or 406, the refusal a running server gives a plain GET; and any remote answers with a 401 whose WWW-Authenticate challenge names resource_metadata, as MCP authorization requires. A bare 401 is not counted, because any authorization wall returns one. A remote is not usable when the card itself marks it status declared - a promise is not a transport - or when it is on the scanned origin, the scan requested it, and it answered anything else. It partially passes when the card is usable but no remote on the scanned origin answered: every remote is on another origin, was not requested, or did not answer, and the detail says which. It also partially passes when the JSON parses and carries name, version and description but no usable remotes entry. A $schema property is accepted when present but is deliberately NOT required: the schema URLs published for this extension do not resolve, and a check that demands a dead URL cannot be satisfied.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /.well-known/mcp/server-card.json /.well-known/mcp.json /mcp.json; do printf '%s -> ' \"$p\"; curl -sS \"$DOMAIN$p\" | jq -c '{name,version,description,remotes}' 2>/dev/null || echo \"missing or not JSON\"; done",
      "fix": "{\n  \"name\": \"com.example/weather\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Live weather and forecast data for partner agents.\",\n  \"title\": \"Example Weather\",\n  \"websiteUrl\": \"https://your-domain.example/developers\",\n  \"remotes\": [\n    {\n      \"type\": \"streamable-http\",\n      \"url\": \"https://your-domain.example/mcp\",\n      \"supportedProtocolVersions\": [\"2026-07-28\"]\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "Serve the card at /.well-known/mcp/server-card.json and reference it from an AI catalog at /.well-known/ai-catalog.json. Declare a remotes entry only for a transport that answers: one the card marks status declared, or one on your own host that returns an error, is reported as a defect rather than counted as transport. Do not add a $schema property unless you have verified the URL resolves — the ones in circulation currently return 404."
    },
    {
      "id": "openapi",
      "category": "protocol",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "OpenAPI definition",
      "why": "A machine-readable contract so an agent can discover your API and generate correct calls.",
      "skill": "openapi",
      "skillTitle": "Publish an OpenAPI definition",
      "standards": [
        "OpenAPI 3.x"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "The scanner fetches the homepage and follows whatever pointer to an API description the site advertises (a service-desc link from your api-catalog, a Link response header, or a link in the HTML), then parses the target. It passes when that document is OpenAPI 3.x: an openapi version string, info.title, info.version and a non-empty paths object. It partially passes when the document parses as OpenAPI but paths is empty, or when the advertised URL returns a human-readable description or a 404.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS \"$DOMAIN/.well-known/api-catalog\" -H 'Accept: application/linkset+json' | jq -r '.linkset[][\"service-desc\"][]?.href' | while read -r u; do curl -sS \"$u\" | jq '{openapi, title:.info.title, version:.info.version, paths:(.paths|keys|length)}'; done",
      "fix": "{\n  \"openapi\": \"3.1.0\",\n  \"info\": { \"title\": \"Example Public API\", \"version\": \"1.4.0\" },\n  \"servers\": [{ \"url\": \"https://api.your-domain.example/v1\" }],\n  \"paths\": {\n    \"/orders/{orderId}\": {\n      \"get\": {\n        \"operationId\": \"getOrder\",\n        \"summary\": \"Get the status of one order\",\n        \"parameters\": [\n          { \"name\": \"orderId\", \"in\": \"path\", \"required\": true, \"schema\": { \"type\": \"string\" } }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"The order\",\n            \"content\": { \"application/json\": { \"schema\": { \"type\": \"object\" } } }\n          }\n        }\n      }\n    }\n  }\n}",
      "fixLang": "json",
      "fixNote": "A valid document scores nothing on its own: if neither the homepage, a Link header nor your api-catalog points at it, no agent will ever request it."
    },
    {
      "id": "oauth-discovery",
      "category": "protocol",
      "tier": "essential",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "OAuth discovery documents",
      "why": "Required for agents to connect to your authenticated API on their own (RFC 8414 / RFC 9728).",
      "skill": "oauth-discovery",
      "skillTitle": "Publish OAuth discovery documents",
      "standards": [
        "RFC 8414",
        "RFC 9728"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/oauth-authorization-server",
        "/.well-known/openid-configuration",
        "/.well-known/oauth-protected-resource"
      ],
      "sources": [],
      "measures": "The scanner GETs /.well-known/oauth-authorization-server, /.well-known/openid-configuration and /.well-known/oauth-protected-resource with no credentials. It passes when one of them returns JSON matching the format for that path: RFC 8414 or OpenID Connect metadata carrying issuer, authorization_endpoint, token_endpoint and response_types_supported, or RFC 9728 protected resource metadata carrying resource and authorization_servers. For protected resource metadata, the scan also requests the RFC 8414 metadata of the same-origin authorization servers it names, up to a published cap. It partially passes when a document is served at a valid path but omits a member required for that format, or when every same-origin authorization server whose metadata the scan requested publishes none with issuer and token_endpoint. An authorization server on another origin, or one whose metadata was not requested, does not change the outcome, and the detail names it as not requested.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /.well-known/oauth-authorization-server /.well-known/openid-configuration /.well-known/oauth-protected-resource; do printf '%s -> ' \"$p\"; curl -sS \"$DOMAIN$p\" | jq -c '{issuer,token_endpoint,resource,authorization_servers}' 2>/dev/null || echo \"missing or not JSON\"; done",
      "fix": "{\n  \"resource\": \"https://api.your-domain.example/\",\n  \"resource_name\": \"Example API\",\n  \"resource_logo_uri\": \"https://your-domain.example/logo.png\",\n  \"authorization_servers\": [\"https://auth.your-domain.example/\"],\n  \"scopes_supported\": [\"api.read\", \"api.write\"],\n  \"bearer_methods_supported\": [\"header\"]\n}",
      "fixLang": "json",
      "fixNote": "RFC 9728 metadata only works if authorization_servers resolves to a server that publishes its own RFC 8414 metadata (or OpenID Connect discovery) with issuer and token_endpoint, so ship both documents."
    },
    {
      "id": "api-catalog",
      "category": "protocol",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "experimental",
      "conditional": false,
      "label": "API catalog",
      "why": "Lets an agent see which capabilities are callable in a single document (RFC 9727).",
      "skill": "api-catalog",
      "skillTitle": "Publish an API catalog",
      "standards": [
        "RFC 9727"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/api-catalog",
        "/api-catalog.json",
        "/.well-known/ai-plugin.json"
      ],
      "sources": [],
      "measures": "The scanner GETs /.well-known/api-catalog with Accept: application/linkset+json, falling back to /api-catalog.json and the legacy /.well-known/ai-plugin.json, and parses the response as a linkset (RFC 9264). It passes when the linkset array holds at least one entry with an anchor and a service-desc link pointing at an API description, as defined by RFC 9727, and at least one same-origin service-desc or service-doc target that the scan actually requested and that answered 2xx - the anchor and the href are a declaration, and only an observed response identifies a callable API. It partially passes when a linkset is served but its entries carry only service-doc, service-meta or status links, or have no anchor, and also when every service-desc or service-doc target it names is off-origin, outside the probe set or answering an error, so no target was observed to answer.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS -H 'Accept: application/linkset+json' \"$DOMAIN/.well-known/api-catalog\" | jq '.linkset[] | {anchor, \"service-desc\": .[\"service-desc\"], status: .status}'",
      "fix": "{\n  \"linkset\": [\n    {\n      \"anchor\": \"https://your-domain.example/apis/public_api\",\n      \"service-desc\": [\n        { \"href\": \"https://your-domain.example/openapi.json\", \"type\": \"application/json\" }\n      ],\n      \"service-doc\": [\n        { \"href\": \"https://your-domain.example/docs/\", \"type\": \"text/html\" }\n      ],\n      \"status\": [\n        { \"href\": \"https://your-domain.example/status.json\", \"type\": \"application/json\" }\n      ]\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "Serve it as Content-Type application/linkset+json with profile=\"https://www.rfc-editor.org/info/rfc9727\" and keep the anchors and hrefs absolute URLs that actually resolve. A target only counts once it answers: point service-desc at a document the scanner already requests - its API description paths such as /openapi.json are in the probe list - and make it return 200, because an href that is never fetched leaves the catalog partial."
    },
    {
      "id": "agent-skills",
      "category": "protocol",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Agent Skills declaration",
      "why": "Offers ready-made capability packages to agent runtimes.",
      "skill": "agent-skills",
      "skillTitle": "Publish an agent skills index",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/agent-skills/index.json",
        "/.well-known/agent-skills.json",
        "/.well-known/skills.json"
      ],
      "sources": [],
      "measures": "The scanner GETs /.well-known/agent-skills/index.json, /.well-known/agent-skills.json and /.well-known/skills.json and validates the first 200 response as an Agent Skills discovery index. It passes when the index carries a skills array whose entries each supply name, type (skill-md or archive), description, url and a sha256: digest, at least one artifact URL was fetched by the scan, and every artifact URL the scan fetched resolved with an acceptable content type. The scan fetches a sample of up to 10 same-origin artifacts, because the list is target-supplied and an uncapped fetch would let a site dictate the request count; the same-origin URLs beyond that sample are reported in the pass as unsampled rather than quietly counted. An artifact URL on another host is not followed, and it makes the index partial, never passing: the site chose to host it elsewhere, and this scan does not verify what it did not fetch. It partially passes when skills are listed with name, description and url but type or digest is missing, when a fetched artifact returned a non-2xx or the wrong content type, or when any artifact URL is off-origin or was never fetched at all. It fails when no index with a non-empty skills array is served as JSON at the 3 paths, or when a fetched artifact does not hash to the sha256 digest its entry declares. A $schema property is accepted when present but is NOT required: the schema host published for this convention does not resolve, so requiring it would make the check unsatisfiable.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /.well-known/agent-skills/index.json /.well-known/agent-skills.json /.well-known/skills.json; do printf '%s -> ' \"$p\"; curl -sS \"$DOMAIN$p\" | jq -c '{$schema, skills:[.skills[]? | {name,type,url,digest}]}' 2>/dev/null || echo \"missing or not JSON\"; done",
      "fix": "{\n  \"skills\": [\n    {\n      \"name\": \"invoice-reconciliation\",\n      \"type\": \"skill-md\",\n      \"description\": \"Reconcile invoices against payments and flag unmatched line items.\",\n      \"url\": \"/.well-known/agent-skills/invoice-reconciliation/SKILL.md\",\n      \"digest\": \"sha256:9f2c1a4b7d8e0f3a5c6b1d2e4f7089a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6\"\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "An early convention rather than a ratified specification, and it has already renamed its path once. The digest is the SHA-256 of the exact bytes served, so recompute it whenever SKILL.md changes — and keep every artifact on the scanned host, because an off-origin URL is not followed and now makes the whole index partial instead of passing."
    },
    {
      "id": "a2a-card",
      "category": "protocol",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "A2A agent card",
      "why": "A2A v1.0 (Linux Foundation) is the emerging standard for agent-to-agent discovery. Almost no site publishes a card yet, which makes this one of the cheapest places to be early.",
      "skill": "a2a-agent-card",
      "skillTitle": "Publish an A2A agent card",
      "standards": [
        "A2A v1.0"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/agent-card.json",
        "/.well-known/agent.json"
      ],
      "sources": [],
      "measures": "The scanner GETs /.well-known/agent-card.json and the legacy alias /.well-known/agent.json and validates the JSON as an A2A v1.0 AgentCard. It passes when the card carries the required name, description, version, capabilities, defaultInputModes, defaultOutputModes and a non-empty skills array, plus at least one supportedInterfaces entry with url, protocolBinding and protocolVersion so a client knows how to call it. It partially passes when identity and skills are present but supportedInterfaces is absent or empty, leaving no transport to connect to.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /.well-known/agent-card.json /.well-known/agent.json; do printf '%s -> ' \"$p\"; curl -sS \"$DOMAIN$p\" | jq -c '{name,version,interfaces:[.supportedInterfaces[]? | {url,protocolBinding,protocolVersion}],skills:(.skills|length)}' 2>/dev/null || echo \"missing or not JSON\"; done",
      "fix": "{\n  \"name\": \"Example Support Agent\",\n  \"description\": \"Answers product and billing questions for partner agents.\",\n  \"version\": \"1.0.0\",\n  \"supportedInterfaces\": [\n    { \"url\": \"https://your-domain.example/a2a/v1\", \"protocolBinding\": \"JSONRPC\", \"protocolVersion\": \"1.0\" }\n  ],\n  \"provider\": { \"organization\": \"Example Inc.\", \"url\": \"https://your-domain.example\" },\n  \"capabilities\": { \"streaming\": true, \"pushNotifications\": false },\n  \"defaultInputModes\": [\"text/plain\", \"application/json\"],\n  \"defaultOutputModes\": [\"text/plain\", \"application/json\"],\n  \"skills\": [\n    {\n      \"id\": \"billing-question\",\n      \"name\": \"Billing question\",\n      \"description\": \"Answers invoice and subscription questions for one account.\",\n      \"tags\": [\"billing\", \"support\"],\n      \"examples\": [\"Why was my last invoice higher than usual?\"]\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "A2A v1.0 lists transports in supportedInterfaces in preference order (the first entry is preferred) and the card is public by design, so keep credentials and per-tenant detail out of it."
    },
    {
      "id": "a2a-signed",
      "category": "protocol",
      "tier": "emerging",
      "weight": 1,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Cryptographically signed agent card",
      "why": "A2A v1.0 makes identity verifiable through JWS (RFC 7515).",
      "skill": "a2a-card-signing",
      "skillTitle": "Sign the agent card",
      "standards": [
        "RFC 7515"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/agent-card.json",
        "/.well-known/agent.json"
      ],
      "sources": [],
      "measures": "The scanner fetches the A2A card and inspects its signatures array. It passes only when a signature is cryptographically verified: at least one entry is a JWS (RFC 7515) object with base64url protected and signature members whose protected header names alg, typ and kid, and the public key published at the jku URL verifies the signature over the card canonicalized per RFC 8785 with the signatures member removed, as A2A v1.0 requires. It partially passes when a signature is structurally complete but was not verified — the referenced JWKS is on another host or outside the probe set so its response was not observed, the JWKS does not publish the key, or the algorithm carries no digest this scanner can check — and it fails when a key from a published JWKS is available and the signature does not verify against it.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS \"$DOMAIN/.well-known/agent-card.json\" | jq -r '.signatures[0].protected' | python3 -c \"import sys,base64,json;p=sys.stdin.read().strip();p+='='*(-len(p)%4);print(json.dumps(json.loads(base64.urlsafe_b64decode(p)),indent=2))\"",
      "fix": "// npm i jose canonicalize  ->  node sign-agent-card.mjs\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport canonicalize from 'canonicalize';\nimport { CompactSign, exportJWK, generateKeyPair } from 'jose';\n\nconst card = JSON.parse(readFileSync('agent-card.json', 'utf8'));\nconst { privateKey, publicKey } = await generateKeyPair('ES256');\nconst { signatures, ...payload } = card;   // A2A v1.0 excludes signatures\nconst canonical = canonicalize(payload);   // RFC 8785 canonical JSON\n\nconst jws = await new CompactSign(new TextEncoder().encode(canonical))\n  .setProtectedHeader({\n    alg: 'ES256', typ: 'JOSE', kid: 'key-1',\n    jku: 'https://your-domain.example/.well-known/jwks.json'\n  })\n  .sign(privateKey);\n\nconst [protectedHeader, signature] = jws.split('.');\nconst signed = { ...card, signatures: [{ protected: protectedHeader, signature }] };\nwriteFileSync('agent-card.signed.json', JSON.stringify(signed, null, 2));\nconst jwk = { ...(await exportJWK(publicKey)), kid: 'key-1', use: 'sig', alg: 'ES256' };\nwriteFileSync('jwks.json', JSON.stringify({ keys: [jwk] }, null, 2));",
      "fixLang": "javascript",
      "fixNote": "Serve agent-card.signed.json at /.well-known/agent-card.json and jwks.json at the jku URL, and re-sign whenever the card changes; the scanner now verifies the JWS over the RFC 8785 canonical card, so a stale signature over an edited card fails instead of being reported as signed."
    },
    {
      "id": "auth-md",
      "category": "protocol",
      "tier": "emerging",
      "weight": 2,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "/auth.md agent authentication guide",
      "why": "A guide telling an agent which authentication flow to use.",
      "skill": "auth-md",
      "skillTitle": "Publish an auth.md guide",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/auth.md",
        "/.well-known/auth.md"
      ],
      "sources": [],
      "measures": "The scanner GETs /auth.md and /.well-known/auth.md and reads the body. It passes when one returns 200 with markdown that names the registration methods the service accepts (anonymous, service_auth, identity_assertion), the endpoints an agent posts to (identity_endpoint, claim_endpoint) and the pointer to the service's OAuth protected-resource metadata. It partially passes when the file exists but describes only a human login page, with no method names or machine-readable endpoints.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /auth.md /.well-known/auth.md; do printf '%s matches=' \"$p\"; curl -sS \"$DOMAIN$p\" | grep -cE 'identity_endpoint|claim_endpoint|identity_assertion|service_auth'; done",
      "fix": "# auth.md - how agents authenticate with Example\nYou are an agent acting for a user. This service supports agentic registration:\ndiscover -> register -> claim if needed -> exchange -> call.\n\n## 1. Discover\nFetch the protected resource metadata for the API host:\n\n    GET https://api.your-domain.example/.well-known/oauth-protected-resource\n\n`resource` is the audience to request; `authorization_servers[0]` is the\nauthorization server whose metadata carries the `agent_auth` block.\n\n## 2. Pick a method\n- `identity_assertion` - you hold an ID-JAG bound to this service's `resource`.\n- `service_auth` - you only have the user's verified email; a claim ceremony is required.\n- `anonymous` - no user identity yet; claim later if the user wants ownership.\n\n## 3. Register and exchange\nPOST to `agent_auth.identity_endpoint` with your chosen `type`, then exchange the\nreturned assertion at the authorization server's `token_endpoint` for an\naccess_token and send it as `Authorization: Bearer <token>`.\n\n## 4. Revocation\nSubscribe to `agent_auth.events_endpoint` and stop using a revoked identity.",
      "fixLang": "markdown",
      "fixNote": "auth.md is a young convention (WorkOS-authored, spec dated June 2026): the file is the agent-readable skill, while the machine-readable contract lives in the agent_auth block of your /.well-known/oauth-authorization-server metadata, so publish both."
    },
    {
      "id": "webmcp",
      "category": "protocol",
      "tier": "emerging",
      "weight": 2,
      "impact": "medium",
      "maturity": "experimental",
      "conditional": false,
      "label": "WebMCP browser tool declaration",
      "why": "WebMCP lets sites expose tools to in-browser agents. It is the only normative deliverable of the W3C Agentic Web WG.",
      "skill": "webmcp",
      "skillTitle": "Declare WebMCP tools",
      "standards": [
        "W3C WebMCP"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/webmcp.json",
        "/.well-known/webmcp",
        "/webmcp.json"
      ],
      "sources": [
        "$home"
      ],
      "measures": "The scanner GETs /.well-known/webmcp.json, /.well-known/webmcp and /webmcp.json, and also inspects the homepage. It passes when it finds a tool declaration, either a tools array whose entries each give a name, a description and a JSON Schema inputSchema, or the equivalent in-page document.modelContext.registerTool({...}) calls. It partially passes when tools are declared without an inputSchema or description, or when the page only loads the WebMCP API without registering any tool.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /.well-known/webmcp.json /.well-known/webmcp /webmcp.json; do printf '%s -> ' \"$p\"; curl -sS \"$DOMAIN$p\" | jq -c '[.tools[]? | {name,description,inputSchema}]' 2>/dev/null || echo \"missing or not JSON\"; done",
      "fix": "{\n  \"tools\": [\n    {\n      \"name\": \"search_products\",\n      \"title\": \"Search products\",\n      \"description\": \"Search the public catalogue by free-text query and return matching products with prices.\",\n      \"inputSchema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"query\": { \"type\": \"string\", \"description\": \"Free-text search query\" },\n          \"limit\": { \"type\": \"integer\", \"minimum\": 1, \"maximum\": 50, \"default\": 10 }\n        },\n        \"required\": [\"query\"],\n        \"additionalProperties\": false\n      }\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "WebMCP is a W3C Community Group Draft (explicitly not on the W3C Standards Track) whose normative surface is the in-page document.modelContext.registerTool() API, so ship those calls as well; no well-known manifest is defined by the spec."
    },
    {
      "id": "ard-manifest",
      "category": "protocol",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "experimental",
      "conditional": false,
      "label": "ARD resource discovery manifest",
      "why": "Cisco/Outshift's Agentic Resource Discovery specification; Lighthouse audits it too.",
      "skill": "ard",
      "skillTitle": "Publish an ARD resource manifest",
      "standards": [
        "ard-spec"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/.well-known/ard.json",
        "/.well-known/agent-resources.json",
        "/.well-known/ai-catalog.json",
        "/ard.json"
      ],
      "sources": [],
      "measures": "The scanner GETs /.well-known/ard.json, /.well-known/agent-resources.json, /.well-known/ai-catalog.json and /ard.json and parses the first 200 response. It passes when the document is an ARD manifest: an entries array whose items each carry the ARD terms identifier, displayName and type, plus exactly one of url or data. It partially passes when entries exist but lack identifier or type, or carry both url and data, which the specification forbids.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /.well-known/ard.json /.well-known/agent-resources.json /.well-known/ai-catalog.json /ard.json; do printf '%s -> ' \"$p\"; curl -sS \"$DOMAIN$p\" | jq -c '[.entries[]? | {identifier,displayName,type,url}]' 2>/dev/null || echo \"missing or not JSON\"; done",
      "fix": "{\n  \"entries\": [\n    {\n      \"identifier\": \"urn:air:your-domain.example:server:support\",\n      \"displayName\": \"Example Support MCP Server\",\n      \"type\": \"application/mcp-server-card+json\",\n      \"url\": \"https://your-domain.example/.well-known/mcp/server-card.json\",\n      \"description\": \"Tools for order status and returns.\",\n      \"representativeQueries\": [\"where is order 12345\"]\n    },\n    {\n      \"identifier\": \"urn:air:your-domain.example:agent:support\",\n      \"displayName\": \"Example Support Agent\",\n      \"type\": \"application/a2a-agent-card+json\",\n      \"url\": \"https://your-domain.example/.well-known/agent-card.json\",\n      \"description\": \"Agent-to-agent endpoint for support conversations.\"\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "ARD v0.91 defines the manifest at /.well-known/ard.json and honours a rel=\"ard\" link or HTML <link rel=\"ard\">, while /.well-known/ai-catalog.json is the predecessor path that conformant consumers may skip, so serve ard.json as canonical."
    },
    {
      "id": "docs",
      "category": "protocol",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Documentation reachable",
      "why": "An agent needs documentation to complete an integration on its own.",
      "skill": "documentation",
      "skillTitle": "Publish machine-readable documentation",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "The scanner fetches the homepage and looks for public documentation it can reach without credentials: a service-doc link relation in a Link header, the HTML or the api-catalog, or an anchor whose path has a whole segment naming a documentation section, such as /docs, /developers, /reference or /guides. It requests the first homepage pointers, service-doc first, up to a published cap, and also reads the standard documentation paths in the fixed probe list. It reads an api-catalog pointer without requesting it, so that pointer counts only when the scan requested its path for another reason. It passes when a pointer or a standard path returns 200 with readable HTML or markdown integration content. It partially passes when an advertised pointer that was requested 404s, redirects to sign-in, or serves only a client-side JavaScript shell. A requested pointer that gives no response is not measured and earns no partial credit. It fails when nothing readable was found, and the detail says how many advertised pointers the scan requested.",
      "verify": "DOMAIN=https://your-domain.example\nfor p in /docs /documentation /developers; do curl -sS -L -o /dev/null -w \"%{http_code} %{content_type} %{url_effective}\\n\" \"$DOMAIN$p\"; done",
      "fix": "<!-- Homepage <head>: tell agents where the public docs are -->\n<link rel=\"service-doc\" href=\"/docs/\" type=\"text/html\">\n\n<!-- Homepage body: a real link, so crawlers and users both find it -->\n<a href=\"/docs/\">Documentation and API reference</a>",
      "fixLang": "html",
      "fixNote": "Docs must return 200 for an anonymous request; the same relation can be sent as a Link: </docs/>; rel=\"service-doc\" response header for agents that never parse HTML."
    },
    {
      "id": "pricing-structured",
      "category": "protocol",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "Structured pricing information",
      "why": "An offer without a price cannot be compared by an agent — you drop out of consideration.",
      "skill": "pricing-schema",
      "skillTitle": "Publish pricing as structured data",
      "standards": [
        "schema.org/Offer"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "The scanner parses the JSON-LD in the server-rendered homepage and looks for schema.org pricing: an Offer (or a Product or Service with offers) carrying price, priceCurrency and availability, or a priceSpecification (UnitPriceSpecification) with price and priceCurrency for recurring plans. It passes when at least one complete offer states a numeric price and its currency; it partially passes when an offer node exists but omits priceCurrency, or when the price appears only as prose or an image.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS \"$DOMAIN/\" | tr -d '\\n' | grep -oE '\"(price|priceCurrency|availability|priceSpecification|@type)\":(\"[^\"]*\"|\\{)' | sort -u",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Service\",\n  \"name\": \"Agent Readiness Audit\",\n  \"serviceType\": \"Website agent-readiness audit\",\n  \"provider\": { \"@type\": \"Organization\", \"name\": \"Example Inc.\", \"url\": \"https://your-domain.example\" },\n  \"offers\": {\n    \"@type\": \"Offer\",\n    \"price\": \"490.00\",\n    \"priceCurrency\": \"USD\",\n    \"availability\": \"https://schema.org/InStock\",\n    \"url\": \"https://your-domain.example/pricing\",\n    \"priceSpecification\": {\n      \"@type\": \"UnitPriceSpecification\",\n      \"price\": \"490.00\",\n      \"priceCurrency\": \"USD\",\n      \"billingDuration\": { \"@type\": \"QuantitativeValue\", \"value\": 1, \"unitCode\": \"MON\" }\n    }\n  }\n}",
      "fixLang": "json",
      "fixNote": "Wrap this in a <script type=\"application/ld+json\"> block in the HTML the server sends: a price rendered only by client-side JavaScript, or shown inside an image, is invisible to the scanner."
    },
    {
      "id": "service-schema",
      "category": "protocol",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "Service/product scope machine-readable",
      "why": "An agent must be able to answer \"what does this site offer, and to whom\".",
      "skill": "service-schema",
      "skillTitle": "Add a service scope schema",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "The scanner reads the homepage JSON-LD and looks for a schema.org statement of what is offered and to whom: a Service (or Product) node with serviceType or category, a provider, and areaServed or audience. It passes when such a node names the service type, the provider organization and the served area or audience; it partially passes when only a generic Organization or WebSite node is present, so an agent cannot tell what is offered or which customers it is for.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS \"$DOMAIN/\" | tr -d '\\n' | grep -oE '\"@type\":\"(Service|Product|ProfessionalService|Organization)\"|\"(serviceType|category|provider|areaServed|audience)\":' | sort -u",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Service\",\n  \"name\": \"Agent readiness audit\",\n  \"serviceType\": \"Website agent-readiness audit and remediation\",\n  \"category\": \"Consulting\",\n  \"provider\": { \"@type\": \"Organization\", \"name\": \"Example Inc.\", \"url\": \"https://your-domain.example\" },\n  \"areaServed\": [\n    { \"@type\": \"Country\", \"name\": \"United States\" },\n    { \"@type\": \"Country\", \"name\": \"Germany\" }\n  ],\n  \"audience\": { \"@type\": \"BusinessAudience\", \"name\": \"E-commerce and SaaS teams\" },\n  \"termsOfService\": \"https://your-domain.example/terms\",\n  \"availableChannel\": {\n    \"@type\": \"ServiceChannel\",\n    \"serviceUrl\": \"https://your-domain.example/contact\",\n    \"availableLanguage\": [\"en\", \"de\"]\n  }\n}",
      "fixLang": "json",
      "fixNote": "Give provider a full Organization node with name and url rather than a bare string, and list every audience and region you serve, because agents filter on areaServed and audience before they read any page."
    },
    {
      "id": "contact",
      "category": "protocol",
      "tier": "emerging",
      "weight": 1,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Structured contact information",
      "why": "An agent must know where to reach you before starting a transaction, or when something fails.",
      "skill": "contact-schema",
      "skillTitle": "Add structured contact information",
      "standards": [
        "schema.org/Organization"
      ],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [],
      "sources": [],
      "measures": "The scanner parses the homepage JSON-LD for schema.org contact data: an Organization carrying contactPoint (a ContactPoint with contactType and email or telephone), or email/telephone on the organization itself. It passes when at least one reachable channel is declared with a contactType that says what it is for; it partially passes when the organization node exists but has no contact details, or when the details appear only as plain text on the page.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS \"$DOMAIN/\" | tr -d '\\n' | grep -oE '\"contactPoint\":|\"(contactType|email|telephone)\":\"[^\"]*\"' | sort -u",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Organization\",\n  \"name\": \"Example Inc.\",\n  \"url\": \"https://your-domain.example\",\n  \"contactPoint\": [\n    {\n      \"@type\": \"ContactPoint\",\n      \"contactType\": \"technical support\",\n      \"email\": \"agents@your-domain.example\",\n      \"telephone\": \"+1-555-0100\",\n      \"availableLanguage\": [\"en\", \"de\"],\n      \"areaServed\": \"US\"\n    },\n    {\n      \"@type\": \"ContactPoint\",\n      \"contactType\": \"billing support\",\n      \"email\": \"billing@your-domain.example\"\n    }\n  ]\n}",
      "fixLang": "json",
      "fixNote": "contactType is free text using schema.org's suggested values (for example \"technical support\", \"billing support\"), and the channel must work for an anonymous request rather than only through a logged-in contact form."
    },
    {
      "id": "status-page",
      "category": "protocol",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "adopted",
      "conditional": false,
      "label": "Service status and changelog feed",
      "why": "An agent should be able to learn about outages and API changes in a machine-readable way.",
      "skill": "status-changelog",
      "skillTitle": "Publish a status and changelog feed",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "universal"
      },
      "paths": [
        "/status",
        "/changelog",
        "/releases",
        "/whats-new"
      ],
      "sources": [],
      "measures": "The scanner fetches the homepage and looks for a status and changelog feed it can poll: a link to a status page plus an Atom or RSS feed (application/atom+xml or application/rss+xml), or an equivalent JSON status endpoint. It passes when both a status document and a dated changelog feed are advertised, both were requested by the scan, both returned 200 and the feed carries a readable entry date. It partially passes when only one of the two is linked, when an advertised feed returns an error, when its newest entry is more than 180 days old, measured against the response `Date` header or its date could not be read, and when a linked status page or feed is off-origin or otherwise outside the probe set so its response was never observed — a resource that was not fetched is reported as unobserved, never as healthy.",
      "verify": "DOMAIN=https://your-domain.example\ncurl -sS \"$DOMAIN/\" | grep -oE '<link[^>]+(atom|rss)[^>]*>|href=\"[^\"]*(status|changelog)[^\"]*\"' | sort -u",
      "fix": "<!-- Homepage <head>: advertise machine-readable status and history -->\n<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Service status\"\n      href=\"/status/feed.atom\">\n<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Changelog\"\n      href=\"/changelog/feed.atom\">\n\n<!-- Homepage body: links a user (or a crawler) can follow -->\n<a href=\"/status/\">Service status</a>\n<a href=\"/changelog/\">Changelog</a>",
      "fixLang": "html",
      "fixNote": "A link only counts if it resolves: a feed that 404s, or whose newest <updated> is months old, still fails, and hosted status pages usually expose both a JSON status API and a history feed you can link."
    },
    {
      "id": "product-schema",
      "category": "commerce",
      "tier": "essential",
      "weight": 3,
      "impact": "critical",
      "maturity": "adopted",
      "conditional": true,
      "label": "Product/catalog schema",
      "why": "Product data is the most valuable and the least machine-readable: price, availability and returns are usually rendered for a person to read and never declared for a machine to parse.",
      "skill": "product-schema",
      "skillTitle": "Add product schema",
      "standards": [
        "schema.org/Product"
      ],
      "profile": false,
      "applicability": {
        "scope": "commercial"
      },
      "paths": [],
      "sources": [],
      "measures": "Conditional: reported n/a and excluded from the score unless the scanner has already seen a commercial signal on the site (Product/Offer/AggregateOffer JSON-LD, an add-to-cart or buy-now action, or a cart/checkout link); this check itself never creates the signal. It then flattens the JSON-LD it holds for the homepage and, when reachable, the /products, /shop and /store paths, and passes on any \"@type\": \"Product\", \"Offer\" or \"AggregateOffer\" — a partial (0.7) when that markup exists only on a product or collection page, and a fail when no such type is found anywhere.",
      "verify": "DOMAIN=shop.example.com\nfor p in / /products /shop; do printf '%s: ' \"$p\"; curl -sL \"https://$DOMAIN$p\" | grep -Eo '\"@type\"[[:space:]]*:[[:space:]]*\"(Product|Offer|AggregateOffer)\"' | sort -u | tr '\\n' ' '; echo; done",
      "fix": "<!-- /products/aurora-desk-lamp : the product itself -->\n<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Product\",\n  \"name\": \"Aurora Desk Lamp\",\n  \"sku\": \"ADL-100-BLK\",\n  \"description\": \"Dimmable LED desk lamp, 2700-5000K, USB-C.\",\n  \"brand\": { \"@type\": \"Brand\", \"name\": \"Aurora\" },\n  \"offers\": {\n    \"@type\": \"Offer\",\n    \"url\": \"https://shop.example.com/products/aurora-desk-lamp\",\n    \"price\": \"1299.00\",\n    \"priceCurrency\": \"USD\",\n    \"availability\": \"https://schema.org/InStock\"\n  }\n}\n</script>\n<!-- Homepage / : the same Offer inside an ItemList, so the catalogue is readable from the root -->\n<script type=\"application/ld+json\">\n{ \"@context\": \"https://schema.org\", \"@type\": \"ItemList\", \"itemListElement\": [\n  { \"@type\": \"Offer\", \"url\": \"https://shop.example.com/products/aurora-desk-lamp\", \"price\": \"1299.00\", \"priceCurrency\": \"USD\", \"availability\": \"https://schema.org/InStock\" }\n] }\n</script>",
      "fixLang": "html",
      "fixNote": "A Product that carries no Offer still fails the homepage test, so keep at least one Offer on / — an ItemList whose itemListElement entries are Offers is enough."
    },
    {
      "id": "payment-protocols",
      "category": "commerce",
      "tier": "recommended",
      "weight": 3,
      "impact": "high",
      "maturity": "draft",
      "conditional": true,
      "label": "Agent payment standard support (AP2/ACP/x402/UCP)",
      "why": "The least-implemented surface in the rubric. Accepting an agent payment means choosing a standard and changing a checkout, which is a business decision most sites have not made yet.",
      "skill": "agent-payments",
      "skillTitle": "Declare agent payment standard support",
      "standards": [
        "AP2",
        "ACP",
        "x402",
        "UCP"
      ],
      "profile": false,
      "applicability": {
        "scope": "commercial"
      },
      "paths": [
        "/.well-known/ucp",
        "/.well-known/ucp.json"
      ],
      "sources": [
        "$home"
      ],
      "measures": "Conditional: reported n/a unless the scanner has already detected a commercial signal (Product/Offer JSON-LD, add-to-cart or buy-now copy, a cart/checkout link). It then fetches /.well-known/ucp plus the policy pages it already holds (/ai-policy, /agent-policy, /pricing, /agentic-commerce, /developers, /docs, /faq), strips their tags and counts distinct signals naming AP2, ACP, x402 or UCP - a reachable UCP manifest and a sentence that grants agent purchasing also count, while a sentence that prohibits it does not - a refusal is classified first and named in the detail as the declared policy it is, never silently counted as support - where two or more signals pass, exactly one is a partial (0.7) and none fails.",
      "verify": "DOMAIN=shop.example.com\nfor p in /.well-known/ucp /ai-policy /pricing /docs; do printf '%s: ' \"$p\"; curl -sL \"https://$DOMAIN$p\" | grep -Eoi '\\b(AP2|ACP|x402|UCP)\\b' | sort -u | tr '\\n' ' '; echo; done",
      "fix": "<!-- /ai-policy (or /agentic-commerce): declare which agent payment standards you accept -->\n<section id=\"agent-payments\">\n  <h2>Agent payments</h2>\n  <p>Autonomous agents may complete checkout on this site over the standards below.</p>\n  <ul>\n    <li><strong>AP2</strong> (Agent Payments Protocol, FIDO Alliance): we accept user-signed mandates and\n      enforce <code>scope_amount_max</code>, <code>scope_expiry</code> and a merchant allow-list.</li>\n    <li><strong>ACP</strong> (Agentic Commerce Protocol, OpenAI + Stripe): checkout sessions and our product\n      feed are served from <code>/api/acp/checkout</code>; we remain merchant of record.</li>\n    <li><strong>x402</strong> (Coinbase, Linux Foundation): paid endpoints answer\n      <code>402 Payment Required</code> with a price and a settlement address in USDC.</li>\n    <li><strong>UCP</strong> (Universal Commerce Protocol, Google + Shopify): our capabilities are published\n      in <code>/.well-known/ucp</code>.</li>\n  </ul>\n  <p>Unsigned agents may read the catalogue but cannot create orders; write to\n    <a href=\"mailto:agents@shop.example.com\">agents@shop.example.com</a> for sandbox credentials.</p>\n</section>",
      "fixLang": "html",
      "fixNote": "The scanner strips tags and matches the standard names as plain text, so write AP2, ACP, x402 and UCP verbatim — a logo or a link to someone else's spec page is invisible to it."
    },
    {
      "id": "agent-purchase",
      "category": "commerce",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": true,
      "label": "Agent purchase policy",
      "why": "Liability is the open question in agent commerce, and it is a policy decision rather than a technical one: a site that accepts an agent purchase has to decide who carries the cost when the agent is wrong, and publish that decision where an agent can read it.",
      "skill": "agent-purchase-policy",
      "skillTitle": "Publish an agent purchase policy",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "commercial"
      },
      "paths": [],
      "sources": [],
      "measures": "Conditional: reported n/a unless the scanner has already detected a commercial signal (Product/Offer JSON-LD, add-to-cart or buy-now copy, a cart/checkout link). It reads the policy pages it already holds (/ai-policy, /agent-policy, /terms, /agentic-commerce, /ai) and passes when one page carries a sentence that pairs an agent word with a purchase word — purchase, order, checkout or refund — and grants it to agents. It classifies each such sentence first: a sentence that reads as a prohibition, such as \"AI agents must never purchase products\", is a refusal and is reported as a partial (warn), never as a pass, because the site did not grant the permission this check measures. A page with only one of the two words does not count, and if no page carries both the check fails outright.",
      "verify": "DOMAIN=shop.example.com\nfor p in /ai-policy /agent-policy /terms; do printf '%s: ' \"$p\"; curl -sL \"https://$DOMAIN$p\" | sed 's/<[^>]*>/ /g' | grep -Eio 'agent|purchase|order|checkout|refund' | sort -u | tr '\\n' ' '; echo; done",
      "fix": "<!-- /agent-policy : served as HTML at https://shop.example.com/agent-policy -->\n<article>\n  <h1>Agent policy</h1>\n  <h2>Agent-initiated purchases</h2>\n  <p>Autonomous agents may search the catalogue and create orders on this site. Accepted\n    authorisation: an AP2 mandate signed with the user's passkey, or an ACP shared payment token\n    issued for this merchant. Card numbers typed by an agent are never accepted.</p>\n  <h2>Liability</h2>\n  <p>An order created with a valid mandate is binding on the user who signed it; the agent operator\n    is responsible for staying inside <code>scope_amount_max</code>. Orders without a valid mandate\n    are cancelled and refunded in full.</p>\n  <h2>Spending limits</h2>\n  <p>Default ceiling 2,000 USD per order and 10,000 USD per 30 days; write to\n    <a href=\"mailto:agents@shop.example.com\">agents@shop.example.com</a> to raise it.</p>\n  <h2>Refunds and shipping</h2>\n  <p>14-day free returns; see <a href=\"/returns\">/returns</a> for the machine-readable policy.</p>\n</article>",
      "fixLang": "html",
      "fixNote": "The agent word and the purchase word must appear in the visible text of the same page, so a heading-only mention or a bare link to a PDF does not register."
    },
    {
      "id": "ucp-manifest",
      "category": "commerce",
      "tier": "emerging",
      "weight": 2,
      "impact": "medium",
      "maturity": "draft",
      "conditional": true,
      "label": "UCP manifest (/.well-known/ucp)",
      "why": "Google and Shopify's Universal Commerce Protocol; lets a merchant declare checkout capabilities at transaction time.",
      "skill": "ucp",
      "skillTitle": "Publish a UCP manifest",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "commercial"
      },
      "paths": [
        "/.well-known/ucp",
        "/.well-known/ucp.json"
      ],
      "sources": [
        "$home"
      ],
      "measures": "Conditional: reported n/a unless the scanner has already detected a commercial signal (Product/Offer JSON-LD, add-to-cart or buy-now copy, a cart/checkout link). It requests /.well-known/ucp and then /.well-known/ucp.json, takes the first HTTP 200 and passes when that body parses as JSON with at least two of the top-level keys protocol_version, merchant, capabilities and checkout; valid JSON with fewer than two of those keys is a partial (0.5), and a missing or non-JSON response fails.",
      "verify": "DOMAIN=shop.example.com\ncurl -sL \"https://$DOMAIN/.well-known/ucp\" | jq '{protocol_version, merchant, capabilities, checkout}'",
      "fix": "{\n  \"protocol_version\": \"1.0\",\n  \"merchant\": {\n    \"id\": \"shop.example.com\",\n    \"name\": \"Example Shop\",\n    \"url\": \"https://shop.example.com\",\n    \"country\": \"US\",\n    \"currency\": \"USD\"\n  },\n  \"capabilities\": {\n    \"catalog_search\": true,\n    \"product_detail\": true,\n    \"cart\": true,\n    \"checkout\": true\n  },\n  \"checkout\": {\n    \"endpoint\": \"https://shop.example.com/api/ucp/checkout\",\n    \"states\": [\"Incomplete\", \"RequiresEscalation\", \"ReadyForComplete\"],\n    \"payment_handlers\": [\"ap2\", \"acp\", \"x402\"]\n  },\n  \"updated_at\": \"2026-10-08T00:00:00Z\"\n}",
      "fixLang": "json",
      "fixNote": "Serve it as application/json at exactly /.well-known/ucp (the .json variant is only a fallback) and keep the four top-level keys — extra fields are ignored, missing ones cap the check at partial."
    },
    {
      "id": "returns",
      "category": "commerce",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": true,
      "label": "Return and shipping terms machine-readable",
      "why": "An agent must verify return terms before making a purchase decision.",
      "skill": "return-policy-schema",
      "skillTitle": "Add return and shipping schema",
      "standards": [
        "schema.org/MerchantReturnPolicy"
      ],
      "profile": false,
      "applicability": {
        "scope": "commercial"
      },
      "paths": [],
      "sources": [],
      "measures": "Conditional: reported n/a unless the scanner has already detected a commercial signal (Product/Offer JSON-LD, add-to-cart or buy-now copy, a cart/checkout link). It greps the JSON-LD it holds for the homepage and for the /returns, /shipping and /refund-policy paths for \"@type\": \"MerchantReturnPolicy\" or \"@type\": \"OfferShippingDetails\"; markup found on any of them passes, a returns or shipping page without that schema is a partial (0.65), and neither a page nor the schema fails.",
      "verify": "DOMAIN=shop.example.com\nfor p in / /returns /shipping; do printf '%s: ' \"$p\"; curl -sL \"https://$DOMAIN$p\" | grep -Eo '\"@type\"[[:space:]]*:[[:space:]]*\"(MerchantReturnPolicy|OfferShippingDetails)\"' | sort -u | tr '\\n' ' '; echo; done",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Product\",\n  \"name\": \"Aurora Desk Lamp\",\n  \"offers\": {\n    \"@type\": \"Offer\",\n    \"price\": \"1299.00\",\n    \"priceCurrency\": \"USD\",\n    \"hasMerchantReturnPolicy\": {\n      \"@type\": \"MerchantReturnPolicy\",\n      \"applicableCountry\": \"US\",\n      \"returnPolicyCategory\": \"https://schema.org/MerchantReturnFiniteReturnWindow\",\n      \"merchantReturnDays\": 14,\n      \"returnMethod\": \"https://schema.org/ReturnByMail\",\n      \"returnFees\": \"https://schema.org/FreeReturn\"\n    },\n    \"shippingDetails\": {\n      \"@type\": \"OfferShippingDetails\",\n      \"shippingRate\": { \"@type\": \"MonetaryAmount\", \"value\": \"0\", \"currency\": \"USD\" },\n      \"shippingDestination\": { \"@type\": \"DefinedRegion\", \"addressCountry\": \"US\" }\n    }\n  }\n}",
      "fixLang": "json",
      "fixNote": "Wrap this in <script type=\"application/ld+json\"> on the product page or on /returns; a returns page written as plain prose scores only the 0.65 partial."
    },
    {
      "id": "agent-analytics",
      "category": "commerce",
      "tier": "emerging",
      "weight": 1,
      "impact": "low",
      "maturity": "draft",
      "conditional": true,
      "label": "Agent traffic measurement",
      "why": "If agent traffic is not measured separately, the channel's return is invisible and never funded.",
      "skill": "agent-analytics",
      "skillTitle": "Measure agent traffic separately",
      "standards": [],
      "profile": false,
      "applicability": {
        "scope": "commercial"
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "Conditional: reported n/a unless the scanner has already detected a commercial signal (Product/Offer JSON-LD, add-to-cart or buy-now copy, a cart/checkout link). It reads the homepage document as lowercase text and looks for an analytics platform signature (segment, plausible, posthog, matomo, amplitude, mixpanel, googletagmanager or yandex.metrika) together with a marker that agents are segmented separately, such as an agent_category custom dimension or an explicit agent event - a vendor signature plus that agent marker passes, a vendor with no agent segmentation is a partial (0.8), and no vendor signature at all is a partial (0.5) because nothing about the channel can be attributed.",
      "verify": "DOMAIN=shop.example.com\ncurl -sL \"https://$DOMAIN/\" | grep -Eoi 'segment|plausible|posthog|matomo|amplitude|mixpanel|googletagmanager|yandex\\.metrika|agent_category' | sort -u",
      "fix": "// Agent traffic as its own analytics segment. Verified agents arrive with a signed request and\n// unknown crawlers are matched on user-agent; both are tagged, humans are tagged too.\nconst AGENT_UA = /GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|PerplexityBot|Google-Extended|Applebot-Extended/i;\nconst ua = navigator.userAgent;\nconst match = ua.match(AGENT_UA);\nconst agent_category = match ? 'agent' : 'human';\n\n// Plausible here; swap for your own vendor (gtag, segment.track, posthog.capture).\nwindow.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };\nplausible('pageview', { props: {\n  agent_category: agent_category,\n  agent_vendor: match ? match[0] : 'none'\n} });\n\n// Register agent_category as an event-scoped custom dimension, then report weekly on agent\n// sessions, agent-assisted orders and revenue per agent vendor.",
      "fixLang": "javascript",
      "fixNote": "Crawler agents do not execute JavaScript, so emit the same event server-side or at the edge for non-JS hits — a client-only implementation undercounts exactly the traffic this check is about."
    },
    {
      "id": "inventory-freshness",
      "category": "commerce",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "Inventory and price freshness",
      "why": "An agent that cannot tell whether an item is in stock — or when a price was last valid — will quote a stale offer to a buyer, or refuse to quote at all.",
      "skill": "inventory-freshness",
      "skillTitle": "Declare availability and price validity",
      "standards": [
        "schema.org/Offer"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "ecommerce",
          "marketplace"
        ]
      },
      "paths": [
        "/products",
        "/shop"
      ],
      "sources": [
        "$home"
      ],
      "measures": "Profile-scoped: reported as not scored and excluded from the denominator unless the e-commerce or marketplace profile has been positively identified, and this check never creates that identification itself. In scope, the scanner flattens the Product/Offer JSON-LD it already holds from the homepage and, when reachable, /products and /shop, and passes every Offer that carries an `availability` from the schema.org ItemAvailability set (InStock, OutOfStock, PreOrder, BackOrder, SoldOut, LimitedAvailability) together with an ISO-date `priceValidUntil`; an Offer with availability but no price validity is a partial (0.7), and an Offer with neither is a fail.",
      "verify": "DOMAIN=shop.example.com\ncurl -sL \"https://$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select((.\"@type\"? | if type == \"array\" then .[] else . end) == \"Offer\") | [(.url // \"NO url\"), (.availability // \"NO availability\"), (.priceValidUntil // \"NO priceValidUntil\")] | @tsv'",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Product\",\n  \"name\": \"Aurora Desk Lamp\",\n  \"sku\": \"ADL-100-BLK\",\n  \"offers\": {\n    \"@type\": \"Offer\",\n    \"url\": \"https://shop.example.com/products/aurora-desk-lamp\",\n    \"price\": \"1299.00\",\n    \"priceCurrency\": \"USD\",\n    \"availability\": \"https://schema.org/InStock\",\n    \"priceValidUntil\": \"2026-12-31\"\n  }\n}",
      "fixLang": "json",
      "fixNote": "Emit availability and priceValidUntil from live stock and your pricing calendar on every render, because a cached InStock on a sold-out SKU, or a priceValidUntil already in the past, is read as a stale offer that a purchase agent will refuse to quote."
    },
    {
      "id": "seller-identity",
      "category": "commerce",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "adopted",
      "conditional": false,
      "label": "Seller identity declared",
      "why": "On a marketplace an offer is meaningless without knowing who is selling and who is the merchant of record, because that is who the agent has a contract with.",
      "skill": "seller-identity",
      "skillTitle": "Identify the seller and the merchant of record",
      "standards": [
        "schema.org/Organization"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "ecommerce",
          "marketplace"
        ]
      },
      "paths": [
        "/about",
        "/terms"
      ],
      "sources": [
        "$home"
      ],
      "measures": "Profile-scoped: reported as not scored and excluded from the denominator unless the e-commerce or marketplace profile has been positively identified, and this check never creates that identification itself. In scope, the scanner resolves the `seller` of every Offer in the JSON-LD it holds and requires an Organization carrying both a `name` and a `url`, then reads the homepage and the about and terms pages for a merchant-of-record statement naming the legal entity; a named seller plus a merchant of record is a pass, a named seller alone is a partial (0.7), and neither is a fail.",
      "verify": "DOMAIN=shop.example.com\ncurl -sL \"https://$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select((.\"@type\"? | if type == \"array\" then .[] else . end) == \"Offer\") | [(.seller.\"@type\" // \"NO seller\"), (.seller.name // \"NO seller.name\"), (.seller.url // \"NO seller.url\")] | @tsv'",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Offer\",\n  \"url\": \"https://shop.example.com/products/aurora-desk-lamp\",\n  \"price\": \"1299.00\",\n  \"priceCurrency\": \"USD\",\n  \"availability\": \"https://schema.org/InStock\",\n  \"seller\": {\n    \"@type\": \"Organization\",\n    \"name\": \"Aurora Lighting ApS\",\n    \"url\": \"https://shop.example.com/about\",\n    \"legalName\": \"Aurora Lighting ApS\",\n    \"vatID\": \"DK12345678\"\n  }\n}",
      "fixLang": "json",
      "fixNote": "seller is declared per Offer and not per page, so on a marketplace every Offer must name its own seller, and legalName plus vatID are what disambiguate the merchant of record when the trading name differs from the invoicing entity."
    },
    {
      "id": "shipping-costs",
      "category": "commerce",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "adopted",
      "conditional": false,
      "label": "Shipping cost and delivery declared",
      "why": "A purchase agent that cannot compute the delivered total cannot complete a checkout, and returns to the merchant without it.",
      "skill": "shipping-costs",
      "skillTitle": "Publish shipping cost and delivery estimates",
      "standards": [
        "schema.org/OfferShippingDetails"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "ecommerce",
          "marketplace"
        ]
      },
      "paths": [
        "/shipping",
        "/returns"
      ],
      "sources": [
        "$home"
      ],
      "measures": "Profile-scoped: reported as not scored and excluded from the denominator unless the e-commerce or marketplace profile has been positively identified, and this check never creates that identification itself. In scope, the scanner inspects the `shippingDetails` of the Offers it holds and passes when that OfferShippingDetails carries a `shippingRate` MonetaryAmount with `value` and `currency` and a `deliveryTime` ShippingDeliveryTime with `handlingTime` and `transitTime` QuantitativeValues; a rate without a delivery window, or a delivery window without a rate, is a partial (0.7), and neither is a fail.",
      "verify": "DOMAIN=shop.example.com\ncurl -sL \"https://$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select((.\"@type\"? | if type == \"array\" then .[] else . end) == \"OfferShippingDetails\") | [(.shippingRate.value // \"NO rate\"), (.shippingRate.currency // \"NO currency\"), (.deliveryTime.handlingTime.maxValue // \"NO handlingTime\"), (.deliveryTime.transitTime.maxValue // \"NO transitTime\")] | @tsv'",
      "fix": "{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Offer\",\n  \"url\": \"https://shop.example.com/products/aurora-desk-lamp\",\n  \"price\": \"1299.00\",\n  \"priceCurrency\": \"USD\",\n  \"availability\": \"https://schema.org/InStock\",\n  \"shippingDetails\": {\n    \"@type\": \"OfferShippingDetails\",\n    \"shippingRate\": { \"@type\": \"MonetaryAmount\", \"value\": \"49.00\", \"currency\": \"USD\" },\n    \"deliveryTime\": {\n      \"@type\": \"ShippingDeliveryTime\",\n      \"handlingTime\": { \"@type\": \"QuantitativeValue\", \"minValue\": 0, \"maxValue\": 1, \"unitCode\": \"DAY\" },\n      \"transitTime\": { \"@type\": \"QuantitativeValue\", \"minValue\": 2, \"maxValue\": 5, \"unitCode\": \"DAY\" }\n    }\n  }\n}",
      "fixLang": "json",
      "fixNote": "Free shipping still needs an explicit shippingRate with value \"0.00\" rather than an omitted rate, because a purchase agent computes the delivered total from this node and an absent rate reads as unknown rather than as free."
    },
    {
      "id": "sandbox-environment",
      "category": "protocol",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "experimental",
      "conditional": false,
      "label": "Sandbox or test environment documented",
      "why": "An agent cannot safely integrate against a production endpoint; a documented sandbox is the difference between a working trial and a support ticket.",
      "skill": "sandbox-environment",
      "skillTitle": "Publish a sandbox environment",
      "standards": [],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "saas",
          "api"
        ]
      },
      "paths": [
        "/docs/sandbox",
        "/sandbox",
        "/developers/sandbox",
        "/docs"
      ],
      "sources": [],
      "measures": "The scanner reads the OpenAPI description the site advertises and the developer documentation it links, and looks for a non-production base URL declared as such: a servers entry whose host differs from the production host and whose description names a sandbox, staging or test environment, together with a stated way to obtain test credentials and a statement of what the sandbox does not contain. It passes when that base URL was requested by the scan, answered 200 anonymously, and the source declaring it names the URL, the credential path and what the sandbox excludes - a callable base URL and test keys still do not tell an agent whether live data sits behind them. It partially passes when a sandbox is mentioned without a callable base URL, without a credential path or without a statement of what it excludes, and when the declared base URL answered an error or its response was not observed at all - a URL on another host, or one outside the probe set, was never fetched, and an unobserved endpoint is never reported as callable. It is reported as not scored and excluded from the denominator unless the site has been positively identified as a SaaS product or an API/developer platform.",
      "verify": "DOMAIN=https://your-domain.example\n{ curl -sS \"$DOMAIN/openapi.json\" | jq -r '.servers[]?.url' 2>/dev/null | grep -iE 'sandbox|staging|test'; curl -sSL \"$DOMAIN/docs/sandbox\" | grep -oiE 'https://[a-z0-9.-]*(sandbox|staging)[a-z0-9./_-]*'; } | sort -u | while read -r u; do printf '%s -> ' \"$u\"; curl -sS -o /dev/null -w '%{http_code} %{content_type}\\n' -L \"$u\"; done",
      "fix": "{\n  \"openapi\": \"3.1.0\",\n  \"info\": {\n    \"title\": \"Example API\",\n    \"version\": \"1.4.0\",\n    \"description\": \"Sandbox base URL: https://api.sandbox.example.com/v1 - the same contract as production over separate data. Test keys are issued at https://example.com/docs/sandbox. The sandbox holds no live payments and no real customer records, and its data resets nightly.\"\n  },\n  \"servers\": [\n    { \"url\": \"https://api.example.com/v1\", \"description\": \"Production - live data and live billing\" },\n    { \"url\": \"https://api.sandbox.example.com/v1\", \"description\": \"Sandbox - test keys only, data reset nightly\" }\n  ],\n  \"paths\": {\n    \"/ping\": {\n      \"get\": {\n        \"operationId\": \"ping\",\n        \"summary\": \"Liveness check, safe to call with a sandbox key\",\n        \"responses\": { \"200\": { \"description\": \"Service reachable\" } }\n      }\n    }\n  }\n}",
      "fixLang": "json",
      "fixNote": "Merge the two servers entries into the OpenAPI document you already publish rather than replacing it: OpenAPI standardises servers but says nothing about test keys, sandbox tenants or data resets, so those can only be stated as info.description prose — and the sandbox has to be its own origin that answers an anonymous request. The scanner only counts a base URL whose response it observed, so a sandbox on a different host from the scanned site is reported as unobserved rather than as callable. The declaring source also has to say what the sandbox excludes — no live data, test data only, resets nightly — because that statement is part of what the check requires before it will pass."
    },
    {
      "id": "rate-limits-declared",
      "category": "protocol",
      "tier": "recommended",
      "weight": 1,
      "impact": "medium",
      "maturity": "experimental",
      "conditional": false,
      "label": "Rate limits and quotas documented",
      "why": "An agent that does not know the quota discovers it by being throttled, halfway through a task it cannot resume.",
      "skill": "rate-limits",
      "skillTitle": "Document rate limits and quotas",
      "standards": [
        "RFC 9110"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "api"
        ]
      },
      "paths": [
        "/rate-limits.json",
        "/.well-known/rate-limits.json",
        "/docs/rate-limits",
        "/docs"
      ],
      "sources": [
        "$home"
      ],
      "measures": "The scanner makes one unauthenticated request to a public endpoint of the API and records any quota declaration the response carries (RateLimit or RateLimit-Policy, or the never-standardised X-RateLimit-* family), then reads the developer documentation and any machine-readable declaration the site links (/rate-limits.json, /.well-known/rate-limits.json and /docs/rate-limits) for a stated limit with its unit, its window, the scope it applies to and the behaviour when it is exceeded. It passes when the documentation states all four and a throttled response carries Retry-After, partially passes when limits are mentioned without numbers or windows, or when quota headers appear with nothing documented, and it is reported as not scored and excluded from the denominator unless the site has been positively identified as an API/developer platform.",
      "verify": "DOMAIN=https://your-domain.example\n{ curl -sS \"$DOMAIN/rate-limits.json\" | jq -c '{policies:[.policies[]?|{name,scope,limit,unit,window}],onExceeded}' 2>/dev/null || echo 'no rate-limits.json'; curl -sSL \"$DOMAIN/docs/rate-limits\" | grep -oiE '[0-9,]+ (requests?|calls?) per (second|minute|hour|day)|429|retry-after'; curl -sS -D - -o /dev/null \"$DOMAIN/v1/ping\" | grep -iE '^(HTTP/|ratelimit|retry-after|x-ratelimit)'; } | head -25",
      "fix": "## Rate limits\n\nEvery endpoint is limited per API key; anonymous requests are limited per IP.\n\n| Scope | Limit | Window | On exceed |\n| --- | --- | --- | --- |\n| API key | 1000 requests | 1 hour | 429 + Retry-After (seconds) |\n| API key, burst | 20 requests | 1 second | 429 + Retry-After (seconds) |\n| Anonymous, per IP | 60 requests | 1 minute | 429 + Retry-After (seconds) |\n\nResponses carry `RateLimit-Policy` and `RateLimit`; a throttled request returns `429` (RFC 6585) with `Retry-After` (RFC 9110).\nMachine-readable copy, linked from this page: https://your-domain.example/rate-limits.json\n```json\n{\n  \"version\": 1,\n  \"policies\": [\n    { \"name\": \"default\", \"scope\": \"per-api-key\", \"limit\": 1000, \"unit\": \"requests\", \"window\": \"1h\" },\n    { \"name\": \"burst\", \"scope\": \"per-api-key\", \"limit\": 20, \"unit\": \"requests\", \"window\": \"1s\" },\n    { \"name\": \"anonymous\", \"scope\": \"per-ip\", \"limit\": 60, \"unit\": \"requests\", \"window\": \"1m\" }\n  ],\n  \"onExceeded\": { \"status\": 429, \"retryAfter\": \"seconds\" },\n  \"increase\": \"https://your-domain.example/docs/rate-limits#increases\"\n}\n```",
      "fixLang": "markdown",
      "fixNote": "No IETF standard covers a machine-readable quota declaration — RateLimit and RateLimit-Policy are still an Internet-Draft (draft-ietf-httpapi-ratelimit-headers, a WG document and not an RFC) and the widely deployed X-RateLimit-* family was never specified by anyone — so publish the numbers as this documented local convention and make the server actually enforce them, because a page that promises a limit the API does not return 429 with Retry-After for is worse for an agent than no documentation at all."
    },
    {
      "id": "licensing-terms",
      "category": "content",
      "tier": "recommended",
      "weight": 2,
      "impact": "high",
      "maturity": "draft",
      "conditional": false,
      "label": "Reuse and licensing terms declared",
      "why": "Declaring ai-train=no without stating a licensing path leaves both the publisher and the agent without an answer they can act on.",
      "skill": "licensing",
      "skillTitle": "Declare reuse and licensing terms",
      "standards": [
        "TDM Reservation Protocol"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "publisher",
          "institution"
        ]
      },
      "paths": [
        "/terms",
        "/licensing",
        "/.well-known/tdmrep.json"
      ],
      "sources": [
        "$home",
        "$robots"
      ],
      "measures": "The scanner reads the homepage `/` response headers for `tdm-reservation` and `tdm-policy` and the homepage HTML head for `<meta name=\"tdm-reservation\">` and `<meta name=\"tdm-policy\">` - the two carriers that need no extra request; the site-wide `/.well-known/tdmrep.json` file is the third carrier the TDM Reservation Protocol defines and is read when that path is in the probe map. Values are resolved in the protocol's own precedence order, HTML meta over HTTP header over file. Pass when `tdm-reservation` is `1` or `0` and, if it is `1`, `tdm-policy` points at a reachable TDM policy or licensing page, so the agent knows both that mining is reserved and how to ask for a licence; partial when a declaration exists but `tdm-reservation` carries any other value, which the protocol defines as a protocol error that agents must treat as unset, or when rights are reserved with no licensing path at all; fail when none of the carriers is present. This profile-scoped check is reported as not scored and excluded from the denominator unless the publisher or institution profile has been positively identified.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sSL -D - \"$DOMAIN/\" \"$DOMAIN/.well-known/tdmrep.json\" | grep -iE '^(HTTP/|tdm-)|tdm-(reservation|policy)'",
      "fix": "# nginx: declare TDM rights on the responses agents actually fetch.\n# tdm-reservation: 1 reserves TDM rights (the Article 4 opt-out) and tdm-policy is\n# the licensing path an agent must read before mining. If you cannot set headers on\n# your host, the same two values go in the HTML head instead, as\n# <meta name=\"tdm-reservation\" content=\"1\"> and <meta name=\"tdm-policy\" content=\"...\">.\nserver {\n    listen 443 ssl;\n    server_name example.com;\n    root /var/www/example.com;\n\n    location / {\n        add_header tdm-reservation \"1\" always;\n        add_header tdm-policy \"https://www.example.com/.well-known/tdm-policy.json\" always;\n    }\n\n    # The licensing path: an ODRL 2.2 offer naming the rightsholder to contact.\n    location = /.well-known/tdm-policy.json {\n        default_type application/ld+json;\n        return 200 '{\"@context\":[\"http://www.w3.org/ns/odrl.jsonld\",{\"tdm\":\"http://www.w3.org/ns/tdmrep#\"}],\"uid\":\"https://www.example.com/policies/tdm-1\",\"@type\":\"Offer\",\"profile\":\"http://www.w3.org/ns/tdmrep\",\"assigner\":{\"uid\":\"https://www.example.com\",\"vcard:fn\":\"Example Publishing Ltd\",\"vcard:hasEmail\":\"mailto:licensing@example.com\",\"vcard:hasURL\":\"https://www.example.com/licensing\"},\"permission\":[{\"action\":\"tdm:mine\",\"duty\":[{\"action\":\"obtainConsent\"}]}]}';\n    }\n}",
      "fixLang": "nginx",
      "fixNote": "Only `0` and `1` are valid - every other value is a protocol error that agents must read as unset - and because adoption of the protocol is still early, the conservative form only pays off if the `tdm-policy` URL resolves today."
    },
    {
      "id": "provenance-declared",
      "category": "content",
      "tier": "emerging",
      "weight": 1,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Content provenance declared",
      "why": "Author, publication date and update date are what let an agent judge whether a source is current and attributable before it quotes it.",
      "skill": "provenance",
      "skillTitle": "Declare author, publication and update dates",
      "standards": [
        "schema.org/author",
        "C2PA"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "publisher",
          "institution"
        ]
      },
      "paths": [],
      "sources": [
        "$home"
      ],
      "measures": "This check declares no probe path, so the scanner walks the JSON-LD of the already fetched homepage `/` for an article-level type (`Article`, `NewsArticle`, `BlogPosting`, `ScholarlyArticle`, `Report`) and reads `author`, `datePublished` and `dateModified` from it. Pass when such a type declares a named `author` (a `Person` or `Organization` with a `name`, not a bare string) plus `datePublished` and `dateModified` as ISO 8601 dates, so an agent can attribute the page and tell how current it is; partial when the entity exists but one of the three is missing, or `author` is an untyped string, or a date is not ISO 8601; fail when no article entity appears, or it carries no author and no dates. The scanner observes the schema.org HTML signals only: C2PA is the other standard named for this check, but it is a binary manifest embedded in a media file and is out of scope for a plain HTML page. This profile-scoped check is reported as not scored and excluded from the denominator unless the publisher or institution profile has been positively identified.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select(([.\"@type\"?] | flatten | map(tostring) | any(test(\"Article|BlogPosting|Report\")))) | [((.author // \"NO AUTHOR\") | if type==\"object\" then (.name // \"unnamed\") elif type==\"array\" then ((.[0].name // .[0]) // \"unnamed\") else . end | tostring), (.datePublished // \"NO datePublished\"), (.dateModified // \"NO dateModified\")] | @tsv'",
      "fix": "<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"NewsArticle\",\n  \"headline\": \"How agent readiness is scored\",\n  \"url\": \"https://www.example.com/insights/agent-readiness-scoring\",\n  \"author\": [\n    { \"@type\": \"Person\", \"name\": \"Dana Whitfield\", \"url\": \"https://www.example.com/authors/dana-whitfield\" },\n    { \"@type\": \"Organization\", \"name\": \"Example Research Desk\", \"url\": \"https://www.example.com/about/research-desk\" }\n  ],\n  \"publisher\": { \"@type\": \"Organization\", \"name\": \"Example Publishing Ltd\", \"url\": \"https://www.example.com/\" },\n  \"datePublished\": \"2025-11-04T09:00:00+01:00\",\n  \"dateModified\": \"2026-02-17T14:30:00+01:00\"\n}\n</script>",
      "fixLang": "html",
      "fixNote": "Dates must be ISO 8601 with a timezone or an agent cannot order two versions of the page, and `dateModified` has to change when the copy changes - a stale update date is worse than none, because it is quoted as fact."
    },
    {
      "id": "booking-action",
      "category": "commerce",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Machine-readable booking action",
      "why": "For a service business the agent's task is to book, not to browse — a declared action is what turns a contact page into a completed job.",
      "skill": "booking-action",
      "skillTitle": "Declare a bookable action",
      "standards": [
        "schema.org/ReserveAction"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "services"
        ]
      },
      "paths": [
        "/contact",
        "/book",
        "/appointment"
      ],
      "sources": [
        "$home"
      ],
      "measures": "This check declares no probe path, so the scanner walks the JSON-LD of the already fetched homepage `/` for a `potentialAction` whose `@type` is `ReserveAction` (schema.org) and inspects its `target`. Pass when the target is an `EntryPoint` carrying a `urlTemplate`, an RFC 6570 URI template with at least one variable for the agent to fill in such as `{date}` or `{service}`, so the booking can be completed by expanding a URL instead of scraping a contact page; partial when `ReserveAction` is present but `target` is a bare URL with no `urlTemplate`, or the template holds no variables and is therefore just a link; fail when no `ReserveAction` appears anywhere in the graph and the only booking path is a `ContactPoint`, a `tel:` link or a free-text form. This profile-scoped check is reported as not scored and excluded from the denominator unless the services profile has been positively identified.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{<script[^>]*application/ld\\+json[^>]*>(.*?)</script>}gsi) { print \"$1\\n\" }' | jq -r '.. | objects | select(([.\"@type\"?] | flatten | any(. == \"ReserveAction\"))) | (.target // empty | if type==\"array\" then .[0] else . end) | if type==\"object\" then [(.\"@type\" // \"NO EntryPoint\"), (.urlTemplate // \"NO urlTemplate\")] else [\"target is a bare URL\", tostring] end | @tsv'",
      "fix": "<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Service\",\n  \"name\": \"Boiler service and safety check\",\n  \"serviceType\": \"Boiler maintenance\",\n  \"areaServed\": { \"@type\": \"City\", \"name\": \"Bristol\" },\n  \"provider\": { \"@type\": \"LocalBusiness\", \"name\": \"Harbourside Heating\", \"telephone\": \"+44-117-496-0000\" },\n  \"potentialAction\": {\n    \"@type\": \"ReserveAction\",\n    \"name\": \"Book a boiler service\",\n    \"target\": {\n      \"@type\": \"EntryPoint\",\n      \"urlTemplate\": \"https://www.example.com/book?service={service}&date={date}&postcode={postcode}\",\n      \"contentType\": \"text/html\",\n      \"actionPlatform\": [\"https://schema.org/DesktopWebPlatform\", \"https://schema.org/MobileWebPlatform\"],\n      \"httpMethod\": \"GET\"\n    },\n    \"result\": { \"@type\": \"Reservation\", \"name\": \"Boiler service appointment\" }\n  }\n}\n</script>",
      "fixLang": "html",
      "fixNote": "A template is a promise: every variable needs a value space an agent can satisfy, and the expanded URL must return the booking form rather than a 404 or a JavaScript-only shell that only a browser can open."
    },
    {
      "id": "document-formats",
      "category": "content",
      "tier": "recommended",
      "weight": 2,
      "impact": "medium",
      "maturity": "draft",
      "conditional": false,
      "label": "Documents in machine-readable formats",
      "why": "A PDF that carries no document structure is unreadable to an agent and to a screen reader alike — the same defect, found twice.",
      "skill": "document-formats",
      "skillTitle": "Publish documents in machine-readable formats",
      "standards": [
        "PDF/UA"
      ],
      "profile": true,
      "applicability": {
        "scope": "profile",
        "profiles": [
          "institution"
        ]
      },
      "paths": [
        "/documents",
        "/publications",
        "/reports"
      ],
      "sources": [],
      "measures": "The scanner collects same-origin links to downloadable documents from the homepage `/` (`.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.odt`) and records the `Content-Type` each one returns. For every PDF it reads the file for the PDF/UA identification marker `pdfuaid:part` in the XMP metadata and for a tagged-PDF structure tree (`/StructTreeRoot`, and `/MarkInfo` with `/Marked true`), the structure on which PDF/UA (ISO 14289) is built. Pass when each linked document is also published as a structured HTML page or as markdown at a stable URL that the scan actually requested and that answered 2xx with an HTML or markdown content type, and any PDF that remains carries a structure tree; partial when an HTML or markdown version is merely linked without its response being observed, when a version exists but the PDFs are untagged, or when a document is offered as a PDF alone; fail when documents are reachable only as untagged PDF or office downloads. A linked-but-404 twin is not an alternative. Those byte-level markers are a necessary precondition rather than a conformance verdict - full PDF/UA validation needs a dedicated validator, and a marker inside a compressed object stream is invisible to this probe. This profile-scoped check is reported as not scored and excluded from the denominator unless the institution profile has been positively identified.",
      "verify": "DOMAIN=\"https://example.com\"\ncurl -sL \"$DOMAIN/\" | perl -0777 -ne 'while (m{href=[\"\\x27]([^\"\\x27]+\\.(?:pdf|docx?|xlsx?|pptx?|odt))[\"\\x27]?}gi) { print \"$1\\n\" }' | sort -u | head -3 | while read -r d; do case \"$d\" in \"$DOMAIN\"*) u=\"$d\";; http*|//*) continue;; /*) u=\"$DOMAIN$d\";; *) u=\"$DOMAIN/$d\";; esac; printf '%s  ' \"$u\"; curl -sIL \"$u\" | grep -i '^content-type' | tail -1 | tr -d '\\r'; curl -sL \"$u\" | strings | grep -cE '/StructTreeRoot|/MarkInfo|pdfuaid'; done",
      "fix": "<!-- Primary form: a structured HTML page at a stable URL. The PDF is the alternate. -->\n<article lang=\"en\">\n  <h1>Annual report 2025</h1>\n  <p class=\"summary\">Audited results, methodology and accounts for the 2025 financial year.</p>\n\n  <table>\n    <caption>Revenue by segment, 2025 (EUR thousands)</caption>\n    <thead>\n      <tr><th scope=\"col\">Segment</th><th scope=\"col\">2024</th><th scope=\"col\">2025</th></tr>\n    </thead>\n    <tbody>\n      <tr><th scope=\"row\">Publishing</th><td>1240</td><td>1385</td></tr>\n      <tr><th scope=\"row\">Research</th><td>860</td><td>910</td></tr>\n    </tbody>\n  </table>\n\n  <p><a href=\"/reports/2025/annual-report.pdf\" type=\"application/pdf\">Annual report 2025 (PDF/UA, tagged)</a></p>\n  <!-- Export the PDF from this same source with PDF/UA tagging: never a scan,\n       and never a hand-written metadata field - the structure is the artifact. -->\n</article>",
      "fixLang": "html",
      "fixNote": "PDF/UA is produced by the export pipeline, not by hand - there is no metadata schema you can paste into a PDF, and a page image that went through a scanner can never be tagged afterwards. The HTML or markdown twin has to answer 200 with an HTML or markdown content type: a link to a twin that 404s, or a twin outside the probe set, is reported as a declared-but-unverified alternative."
    }
  ]
}