Agent Skills SKILL.md for websites: write, index, verify
Write an Agent Skills SKILL.md for your site, list it in /.well-known/agent-skills/index.json, and verify its sha256 digest with one curl script.

An Agent Skill for a website is a SKILL.md file that teaches an agent how to use your site or API, plus an entry in /.well-known/agent-skills/index.json that lets agents find the file and check that it hasn't changed. The file format comes from agentskills.io. The discovery index is a separate draft with five required fields per skill, and the field that is easiest to get silently wrong is digest: a SHA-256 hash of the exact bytes you serve.
Skills are the last layer of the readiness stack. An agent first reaches and reads your pages, then finds what it can call, then learns how to use it well. Make your site agent-callable draws that whole map, with the MCP Server Card saying where to connect and the API catalog listing your APIs.
This post covers that layer in detail. One caveat first: the SKILL.md format is widely supported, but the website index is newer, less widely supported, and still a draft. I could verify one open-source client that reads it.
Key takeaways
- A skill is a
SKILL.mdwith YAML front-matter: aname(max 64 characters, lowercase letters, digits, hyphens) and adescription(max 1,024 characters) that says what it does and when to use it (Agent Skills specification).- Website discovery is a draft, not an IETF RFC: Agent Skills Discovery 0.2.0, updated 12 March 2026. The index lists skills with
name,type,description,url, anddigest(cloudflare/agent-skills-discovery-rfc).digestissha256:plus the hash of the artifact's raw bytes. Clients MUST verify it and MUST NOT use content that fails (same draft).- The cheapest way to avoid drift is to compute the digest from the same bytes the route serves. That's how aiSiteReady does it, and I checked the result from outside with
shasum.- aiSiteReady's
agent_skillscheck validates the index shape and the digest format. It never downloads theSKILL.md, so run the verification script below yourself.
What is an Agent Skill, and how is it different from API docs?
An Agent Skill is a folder with a required SKILL.md (YAML front-matter plus Markdown instructions) and optional scripts/, references/, and assets/ directories (Agent Skills specification). Unlike API docs, which say what exists and where, it tells an agent what to do. Anthropic originally developed the format and released it as an open standard; the client list on agentskills.io now shows more than forty supporting products.
It scales through progressive loading. At startup an agent reads only each skill's name and description, about 100 tokens per skill. When a task matches, it loads the SKILL.md body, under 5,000 tokens. Bundled files load only if the instructions point to them (Anthropic docs).
That separates a skill from API documentation. OpenAPI and a Server Card tell an agent what exists and where. A skill tells it what to do: when it applies, which calls to make in what order, and what to do on failure. It declares no transport or endpoint of its own.
| Front-matter field | Rule |
|---|---|
name (required) | Max 64 characters; lowercase letters, digits, hyphens; no leading, trailing, or double hyphen; must match the parent directory name |
description (required) | 1 to 1,024 characters; says what the skill does and when to use it |
The optional fields are license, compatibility, metadata, and the experimental allowed-tools. Anthropic's platform adds two rules: no XML tags in either required field, and no anthropic or claude in a name. Because the name must match the folder, a website skill called scan-url naturally lives at /.well-known/agent-skills/scan-url/SKILL.md.
How do agents discover skills on a website?
They fetch one JSON file at a well-known path. The mechanism is the Agent Skills Discovery draft in Cloudflare's GitHub repository: publishers MUST provide /.well-known/agent-skills/index.json, listing every skill (cloudflare/agent-skills-discovery-rfc). As of 2 October 2026 it is version 0.2.0, published 17 January 2026 and last updated 12 March 2026. It builds on RFC 8615 but is not an IETF document, and I found no agent-skills entry in the IANA well-known URI registry, last updated 16 September 2026. Expect details to change.
Here is aiSiteReady's real index, fetched from production on 2 October 2026.
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "scan-url",
"type": "skill-md",
"description": "Submit a public URL and receive an Agent Readiness Score (0–100) with per-check evidence and prioritized, localized remediation guidance for AI-agent and AI-search readiness.",
"url": "https://www.ai-siteready.com/.well-known/agent-skills/scan-url/SKILL.md",
"digest": "sha256:1d7a33bcc768060bbba4328bec09637e6dcc043ada3c85f19f758879bde54ffb"
}
]
}
$schema is an opaque version identifier. Clients should warn on one they don't recognize and skip the index, and treat an absent one as the old 0.1.0 format. Every entry in skills needs all five fields.
| Entry field | Rule from the draft |
|---|---|
name | Same rules as the SKILL.md name |
type | skill-md for one file, or archive for a .tar.gz or .zip with SKILL.md at its root; clients skip unknown types |
description | Max 1,024 characters; SHOULD match the front-matter description |
url | Absolute, path-absolute, or relative to the index URL |
digest | sha256: plus 64 lowercase hex characters of the artifact's raw bytes |
Compare the $schema string; don't fetch it. The draft says it need not resolve, and the host schemas.agentskills.io didn't resolve in DNS when I checked on 2 October 2026.
Who actually reads the index? Support is uneven. The open-source skills installer from Vercel Labs implements version 0.2.0: npx skills add https://your-domain reads the index, downloads every listed skill, checks each hash against digest, and then lets you pick which to install (source, Evil Martians' August 2026 write-up). Elsewhere, support is thin: a May 2026 request for Claude Code to load skills from a well-known URL was closed as inactive a month later, unimplemented.
How do you write a SKILL.md that agents actually use?
Start with the description: it is all the agent sees before it decides. Anthropic's guide says to state what the skill does and when to use it, in the third person, with specific key terms, and warns against vague ones like "Helps with documents" (Skill authoring best practices).
For a website, name the user's situation: "Looks up the status of an order by order number. Use when a user asks where an order is, whether it shipped, or when it will arrive."
The body then needs five sections, in this order.
| Section | What goes in it | Why |
|---|---|---|
| When to use | Situations in the user's own words | Confirms the match |
| How to invoke | Exact endpoints, methods, parameters, a sample request | The agent can't guess your API |
| Output | The shape of the result | Stops invented fields |
| Errors and limits | Each failure, the next step, rate limits | Prevents retry loops |
| Scope and safety | What the skill won't do; no secrets | The text goes straight into agent context |
The last row matters more than it looks. A SKILL.md goes directly into an agent's context, so the draft warns that a malicious one can inject instructions and recommends that clients load skills only from allowlisted origins (draft, security considerations).
Write only about your own site's actions, never ask an agent to send user data anywhere, and say what is out of scope. The draft also says clients SHALL NOT execute files under scripts/ by default, so a website skill shouldn't depend on scripts. Use a single skill-md file under 500 lines (agentskills.io).
Here are the front-matter and the first section of aiSiteReady's own scan-url skill, as served, with the intro paragraph cut.
---
name: scan-url
description: Scan a public http(s) URL for AI-agent and AI-search readiness and return an Agent Readiness Score (0-100) with per-check evidence and prioritized, localized remediation guidance across discoverability, content accessibility, bot governance, protocol/API discovery, and commerce.
license: Apache-2.0
---
# Agent Readiness Scan
## When to use
- A user asks whether a site is discoverable, parseable, or usable by AI agents and
AI-search crawlers.
- You need a structured readiness report (robots/sitemap/llms.txt, structured data,
bot governance, MCP/A2A/API discovery, commerce signals) for a URL.
Reading it against my own checklist, I found three gaps:
- The description says what the skill does but has no "use when" clause; that sentence sits in the body, which the agent reads only after deciding.
- The REST option says to "poll the returned report path", but the
202fromPOST /api/v1/scanscarries anidand anaccess_token, not a path, and a non-public report can't be read without the token. - There is no errors section.
This is what I would add, using the status codes from the live OpenAPI document.
### Option B - REST API
1. `POST /api/v1/scans` with {"url": "https://example.com", "profile": "all", "locale": "en"}.
The `202` response carries `id` and `access_token`.
2. Poll `GET /api/v1/scans/{id}` with the header `X-Scan-Access-Token: <access_token>`
until `status` is `succeeded` or `failed`.
3. Read `GET /api/v1/scans/{id}/report.json` with the same header.
## Errors and limits
- `400` `invalid_scan_url`: the URL is not a public http(s) address. Ask for another one.
- `409` `idempotency_conflict`: the same `Idempotency-Key` was sent with a different body.
- `422` `validation_error` or `invalid_check_selection`: the request body is invalid. Fix it; do not resend it unchanged.
- `429` `rate_limited`: wait the number of seconds in `Retry-After`, then retry once.
- `503` `queue_unavailable`: try again later; do not loop.
- Only public pages are scanned: no logins, no remote JavaScript.
If you describe limits in the skill, publish them as headers too: see RateLimit-Policy headers for AI agents.
How should you serve the index and the SKILL.md?
As static, public, cacheable files with honest media types. The draft is specific about what servers MUST and SHOULD do (draft, HTTP considerations); the last column is what aiSiteReady's production routes returned on 2 October 2026.
| Resource | Requirement | aiSiteReady |
|---|---|---|
| Index media type | MUST be application/json | application/json; charset=utf-8 |
SKILL.md media type | MUST be text/markdown or text/plain | text/markdown; charset=utf-8 |
| Methods | MUST support GET and HEAD | GET only on 2 October 2026; both since the fix (see below) |
| Missing skill | MUST return 404 | 404 with a JSON body |
| Caching | SHOULD set Cache-Control | public, max-age=3600 |
| CORS | SHOULD set headers for browser consumers | Not set |
HTTP/2 200
content-type: application/json; charset=utf-8
cache-control: public, max-age=3600
HTTP/2 200
content-type: text/markdown; charset=utf-8
cache-control: public, max-age=3600
vary: Accept-Encoding
The url field can be absolute, path-absolute (resolved against the index origin), or relative to the index directory. aiSiteReady publishes absolute URLs, and that has a cost.
The builder takes the origin from NUXT_PUBLIC_SITE_URL; when it's unset, it falls back to the dev origin localhost:5173. The scanner rejects that URL, because localhost has no dot and 5173 is not port 80 or 443, so a production deploy that forgot the variable publishes an index the check rates PARTIAL. Path-absolute URLs would avoid the problem, and the installer I read resolves them with new URL(entry.url, indexUrl), though you should check every client you care about.
For a static site you don't need a framework. This script copies each skill byte for byte into the public folder and writes the index from the same bytes.
// node build-skills-index.mjs https://example.com
// Reads skills/<name>/SKILL.md, copies each file byte for byte into public/,
// and writes an index whose digests hash those same bytes.
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const origin = (process.argv[2] ?? "").replace(/\/$/, "");
if (!origin.startsWith("https://")) throw new Error("usage: node build-skills-index.mjs https://example.com");
const out = "public/.well-known/agent-skills";
const skills = [];
const names = readdirSync("skills", { withFileTypes: true })
.filter((d) => d.isDirectory()) // skip stray files such as .DS_Store
.map((d) => d.name)
.sort();
for (const name of names) {
const bytes = readFileSync(join("skills", name, "SKILL.md")); // raw bytes, never re-encoded
const front = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/.exec(bytes.toString("utf8"))?.[1] ?? "";
const field = (key) =>
new RegExp(`^${key}:\\s*(.+)$`, "m").exec(front)?.[1].trim().replace(/^(["'])(.*)\1$/, "$2");
if (field("name") !== name) throw new Error(`${name}: front-matter name must equal the folder name`);
const description = field("description");
if (!description || /^[>|]/.test(description)) throw new Error(`${name}: front-matter needs a one-line description`);
mkdirSync(join(out, name), { recursive: true });
writeFileSync(join(out, name, "SKILL.md"), bytes); // serve exactly what was hashed
skills.push({
name,
type: "skill-md",
description, // same text as the front-matter: the draft says it SHOULD match
url: `${origin}/.well-known/agent-skills/${name}/SKILL.md`,
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
});
}
const schema = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
writeFileSync(join(out, "index.json"), `${JSON.stringify({ $schema: schema, skills }, null, 2)}\n`);
console.log(`wrote ${skills.length} skill(s) to ${out}`);
Check the content-type your host sends for .md files. Stock nginx has no MIME type for md and sends application/octet-stream, which breaks the draft's MUST; add types { text/markdown md; } to the http block (in a server block it replaces the whole map, JSON included). Also make sure a missing skill returns 404: a single-page app that answers every unknown path with its HTML shell and a 200 will happily "serve" a missing SKILL.md.
How do you keep the digest in sync with the served file?
Compute it from the same bytes you serve, in the same step. The digest is sha256: plus the hash of the artifact's raw bytes, and clients MUST verify it after download and MUST NOT use content that fails (draft, integrity). The Vercel Labs installer does exactly that: fetchArtifactSkillByEntry hashes the downloaded bytes and returns null if the result differs from entry.digest, and fetchAllSkills filters the nulls out. The skill silently disappears from the list; no digest error is ever shown.
So drift is silent: edit the SKILL.md, forget to rebuild the index, and conformant clients stop using your skill with no error you will ever see. Hash the decoded body, not a gzip transfer. Anything that rewrites bytes after you hash them, such as a line-ending conversion or a proxy that injects a footer, breaks the match too.
aiSiteReady avoids the problem structurally. The skill text lives in one TypeScript constant. The route returns that constant, and the digest is computed from it when the module loads. There is no second copy to edit separately.
// the skill constant and its digest (abridged)
import { createHash } from "node:crypto";
export const SCAN_URL_SKILL_MD = `---
name: scan-url
description: Scan a public http(s) URL …
---
…`;
export const SCAN_URL_SKILL_DIGEST = `sha256:${createHash("sha256")
.update(SCAN_URL_SKILL_MD, "utf8")
.digest("hex")}`;
// the SKILL.md route handler (abridged)
export default defineReadOnlyHandler((event) => { // GET and HEAD alike; 405 for other methods
setResponseHeader(event, "Content-Type", "text/markdown; charset=utf-8");
return SCAN_URL_SKILL_MD; // the exact constant the digest was computed from
});
I checked it from the outside on 2 October 2026. The production file is 2,476 bytes, and shasum -a 256 prints 1d7a33bc…bde54ffb, the digest in the index. The skill body is also origin-relative, with paths like /api/mcp/ rather than absolute URLs, so the bytes, and the digest, are identical on every deploy domain.
Hosting skills somewhere you don't control, such as raw GitHub URLs, loses this guarantee: upstream pushes change the bytes under a digest you already published. Evil Martians' rule is to author anywhere but publish from a place you control (Evil Martians).
How do you verify your Agent Skills index?
Check three things: the index parses and has the right shape, every url serves the right media type, and every digest matches the bytes. Three curl commands cover the manual version.
# 1. headers and body of the index
curl -sSi https://example.com/.well-known/agent-skills/index.json | head -20
# 2. every skill and its advertised digest
curl -sS https://example.com/.well-known/agent-skills/index.json | jq -r '.skills[] | [.name, .digest] | @tsv'
# 3. the real hash of a served skill, to compare by eye
curl -sS https://example.com/.well-known/agent-skills/scan-url/SKILL.md | shasum -a 256
For CI, this script does all of it. It needs curl, jq, and shasum (on Linux without it, swap in sha256sum) and exits non-zero on any failure.
#!/usr/bin/env bash
# usage: ./check-skills.sh https://example.com
set -euo pipefail
ORIGIN="${1:?usage: check-skills.sh https://example.com}"
ORIGIN="${ORIGIN%/}"
INDEX="$ORIGIN/.well-known/agent-skills/index.json"
SCHEMA="https://schemas.agentskills.io/discovery/0.2.0/schema.json"
bad=0
ok() { printf 'ok %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*"; bad=1; }
tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT
# 1. The index: status, media type, HEAD support, $schema
meta="$(curl -sS -o "$tmp" -w '%{http_code} %{content_type}' "$INDEX")"
[[ "$meta" == 200\ application/json* ]] && ok "index: GET 200, ${meta#* }" || fail "index: GET -> $meta"
[[ "$(curl -sS -I -o /dev/null -w '%{http_code}' "$INDEX")" == 200 ]] \
&& ok "index: HEAD 200" || fail "index: HEAD is not 200 (the draft says servers MUST support HEAD)"
body="$(cat "$tmp")"
jq -e --arg s "$SCHEMA" '."$schema" == $s' <<<"$body" >/dev/null \
&& ok "index: \$schema is the 0.2.0 URI" || fail "index: missing or unexpected \$schema"
jq -e '.skills | type == "array" and length > 0' <<<"$body" >/dev/null 2>&1 \
&& ok "index: skills[] has entries" || fail "index: skills[] is missing or empty"
# 2. Every entry: fields, URL, media type, front-matter name, and the digest
while read -r entry; do
name="$(jq -r '.name // ""' <<<"$entry")"; url="$(jq -r '.url // ""' <<<"$entry")"
want="$(jq -r '.digest // ""' <<<"$entry")"
type="$(jq -r '.type // ""' <<<"$entry")"
jq -e '.description | type == "string" and length > 0 and length <= 1024' <<<"$entry" >/dev/null \
|| fail "$name: description is missing or longer than 1,024 characters"
[[ "$type" == skill-md ]] || { fail "$name: type is '$type'; this script verifies skill-md entries only"; continue; }
[[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ && ${#name} -le 64 ]] || fail "$name: invalid name"
[[ "$want" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "$name: digest is not sha256:<64 lowercase hex>"
case "$url" in https://*|http://*) ;; /*) url="$ORIGIN$url" ;; *) url="$ORIGIN/.well-known/agent-skills/$url" ;; esac
[[ "$url" == https://* && "$url" != *localhost* ]] || { fail "$name: url is not a public https URL ($url)"; continue; }
meta="$(curl -sS -o "$tmp" -w '%{http_code} %{content_type}' "$url")"
[[ "$meta" == 200\ text/markdown* || "$meta" == 200\ text/plain* ]] \
&& ok "$name: GET 200, ${meta#* }" || fail "$name: GET -> $meta"
got="sha256:$(shasum -a 256 "$tmp" | cut -d' ' -f1)"
[[ "$got" == "$want" ]] && ok "$name: digest matches the served bytes" \
|| fail "$name: digest drift - index says $want, served bytes hash to $got"
head -n 10 "$tmp" | tr -d '\r' | grep -qx "name: $name" \
&& ok "$name: front-matter name matches" || fail "$name: front-matter name differs from the index"
done < <(jq -c '.skills[]?' <<<"$body" 2>/dev/null)
exit "$bad"
Run against aiSiteReady's production site on 2 October 2026, it printed this.
ok index: GET 200, application/json; charset=utf-8
FAIL index: HEAD is not 200 (the draft says servers MUST support HEAD)
ok index: $schema is the 0.2.0 URI
ok index: skills[] has entries
ok scan-url: GET 200, text/markdown; charset=utf-8
ok scan-url: digest matches the served bytes
ok scan-url: front-matter name matches
That failure was real, and it was ours. On 2 October 2026 our well-known routes were GET-only handlers, and a HEAD request to them returned 404, while the draft says servers MUST support both methods. A client that probed with HEAD before downloading would have misjudged our site. The fix was small: the route files lost their .get suffix, and one handler now answers GET and HEAD with the same status and headers, so that line reads ok index: HEAD 200. I only found the bug because I wrote this script, so run yours before you trust your own setup.
What mistakes break an Agent Skills index?
Digest drift, a vague description, and a localhost url cause most broken indexes, and almost all of these failures are silent: a client skips the skill, or a scanner downgrades the check. These are the ones I'd test for first.
| Mistake | What happens | Fix |
|---|---|---|
| Digest drift | Conformant clients refuse the skill; you see nothing | Derive the digest from the served bytes |
Vague description | The agent never picks the skill | Say what it does and "Use when" |
| A skill that needs a login | The agent stalls at step one | Document how to get access, or don't publish it |
localhost or a dev port in url | Our check rejects the URL (no dotted hostname, or a port other than 80/443) and rates the index PARTIAL; a staging hostname passes the check but sends agents to staging | Set the public origin, or use path-absolute URLs |
200 HTML shell for a missing file | A client that skips verification loads HTML as instructions | Return 404 |
Missing or invented $schema | The installer I read treats a missing one as legacy 0.1.0 and skips an unknown one; our check is PARTIAL without one but accepts any non-empty string, even an invented one | Copy the 0.2.0 URI exactly |
Old 0.1.0 shape or the /.well-known/skills/ path | Our check probes only the new path | Move to agent-skills/ with type, url, digest |
| Index description differs from front-matter | Breaks a SHOULD; agents see two stories | Generate both from one source |
The last row is another confession. Our index reuses the description from our A2A Agent Card, while the SKILL.md front-matter carries a different, longer sentence. Both are accurate, but the draft says they SHOULD match. The build script above avoids the problem by reading the description from the front-matter.
How does aiSiteReady check an Agent Skills index?
aiSiteReady's agent_skills check makes one request to /.well-known/agent-skills/index.json and validates it against Agent Skills Discovery 0.2.0: the shape, the five fields, and the digest format, but not the served SKILL.md. Your script covers the rest. It sits in the Protocol/API category beside the MCP Server Card and API catalog checks, and the 30-minute audit shows where it fits in a full pass.
| Status | Condition |
|---|---|
| PASS | $schema declared, JSON content type, and at least one entry with valid name, type, description, url, and digest |
| PARTIAL | Valid entries but no $schema, or a parseable index served with a non-JSON type such as text/plain; entries exist but none is valid; skills is missing or empty; or the JSON doesn't parse |
| UNKNOWN | The index couldn't be fetched safely; a 403 or 429 counts as a block, not a verdict |
| FAIL | No index: a 404 or any other error status except 403/429, or an HTML page (an SPA shell, or JSON sent as text/html) instead of JSON |
Four limits to know:
- The check makes one request and never downloads a
SKILL.md, so it validates the digest's format but can't catch drift; that's what your script is for. - It probes only the well-known path, with no
Linkheader discovery. - The Protocol/API category is off in the Content site profile, so it counts only in the default All checks profile and the API/Application and E-commerce profiles.
- Entry URLs must be absolute http(s) URLs, or resolve to one against the index URL, with a dotted hostname on port 80 or 443. The check never resolves or fetches them, so
http://localhost:5173/…fails while a staging hostname or a private IP passes.
The methodology page shows how much the Protocol/API category (listed there as Protocols) weighs in each profile.
Run a free scan to see whether an agent finds your skills index, which entries pass, and what to fix first, in English, Ukrainian, or Russian. For the full set of layers, start from what AI agent readiness means.
The short version: a skill is a SKILL.md that says when to use it and exactly what to call, the index is five fields per entry, and the digest must be computed from the bytes you serve. Write the description as a trigger, generate the index from the files, and run the verification script on every release.
IMozz has 20 years in software development, with the past year spent building with LLMs. He builds aiSiteReady, a read-only scanner that checks whether AI agents can read and call a site. It server-renders its own content as a working example.