Agent discovery
How agents find your machine-readable surface without guessing — the agent-readability manifest, Link headers, the RFC 9727 API catalog, WebMCP, your site's generated skill and published skills, DNS-based discovery, and Web Bot Auth keys.
Publishing llms.txt, Markdown mirrors, a JSON API, and an MCP server is only half the job — an agent still has to find them. Blume advertises the whole surface through the conventions agents actually probe: a manifest at the site root, Link headers and <link> tags, well-known files, and the browser’s own model context. Everything here is on by default and derived from what you’ve already enabled.
Agent readability
Blume writes an /agent-readability.json manifest at your site root that indexes the agent-facing surface described across this section — so an agent can discover it in a single fetch instead of guessing at conventions or scraping HTML. Like llms.txt, it’s on by default:
agents: {
agentReadability: true,
}
The manifest lists only what you’ve enabled — the raw Markdown mirror pattern, the JSON API and its OpenAPI description, llms.txt and llms-full.txt, the MCP server and its discovery document, the assistant endpoint, the sitemap, and RSS feeds — alongside your site name, description, source repository, and the content-signal usage policy. URLs are absolute when deployment.site is set and root-relative otherwise:
{
"artifacts": {
"markdown": {
"contentNegotiation": "text/markdown",
"pattern": "https://docs.example.com/{route}.md"
},
"api": {
"openapi": "https://docs.example.com/openapi.json",
"pages": "https://docs.example.com/api/docs/pages.json",
"search": "https://docs.example.com/api/docs/search"
},
"llmsFullTxt": "https://docs.example.com/llms-full.txt",
"llmsTxt": "https://docs.example.com/llms.txt",
"mcp": {
"discovery": "https://docs.example.com/.well-known/mcp.json",
"url": "https://docs.example.com/mcp"
}
},
"description": "Docs for the Acme API.",
"generator": "blume@2.0.0",
"name": "Acme Docs",
"site": "https://docs.example.com",
"contentUsage": { "search": true, "ai-input": true, "ai-train": true },
"repository": "https://github.com/acme/docs"
}
The contentNegotiation field appears only when the deployed site actually honors the Accept: text/markdown header — see content negotiation; on every other deployment the manifest advertises just the .md mirror pattern.
Set agents.agentReadability to false to skip it, or ship your own public/agent-readability.json to take over — Blume never overwrites a file you place in public/.
Discovery Link header
Agents that probe a site don’t know to look for the manifest — so Blume also advertises it in an RFC 8288 Link response header on the homepage, using IANA-registered relation types:
Link: </.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json; profile=\"https://www.rfc-editor.org/info/rfc9727\"",
</.well-known/ai-catalog.json>; rel="ai-catalog"; type="application/ai-catalog+json",
</openapi.json>; rel="service-desc"; type="application/json",
</agent-readability.json>; rel="describedby"; type="application/json",
</llms.txt>; rel="describedby"; type="text/plain",
</index.md>; rel="alternate"; type="text/markdown"
Each entry appears only when its feature is on. The alternate link points at the homepage’s Markdown mirror — the page’s own raw Markdown when the home route is a content page, or the synthesized llms.txt fallback when it’s a landing page. The service-desc link (RFC 8631) points at the JSON API’s OpenAPI description, api-catalog at the generated API catalog, and ai-catalog at the AI catalog. The header rides on every surface Blume controls: static builds via the emitted _headers file (Netlify and Cloudflare), Vercel server builds via the deploy’s routing rules, and the dev server (check it with curl -I localhost:4321). The dev server’s header lists only what it serves — the service-desc and alternate links — because blume build writes the catalogs, agent-readability.json, and llms.txt into the build output.
Not every agent enters through the root, though — one following a search result or a shared link lands on a deep page and never sees the homepage header. So every rendered page also carries the same discovery links in its HTML <head>, using the same IANA-registered relations:
<link
rel="describedby"
href="/agent-readability.json"
type="application/json"
/>
<link rel="describedby" href="/llms.txt" type="text/plain" />
<link
rel="ai-catalog"
href="/.well-known/ai-catalog.json"
type="application/ai-catalog+json"
/>
<link rel="ard" href="/.well-known/ard.json" type="application/json" />
<link rel="alternate" href="/docs/example.md" type="text/markdown" />
Here the alternate link points at that page’s own raw-Markdown mirror, so an agent can jump straight from the HTML it landed on to the token-efficient version. Because the head links travel with the prerendered HTML, they also work on hosts that ignore _headers and can’t send custom response headers at all (GitHub Pages, S3) — no matter which page the agent enters on.
API catalog
When the site publishes APIs, Blume generates an RFC 9727 API catalog at /.well-known/api-catalog — a linkset that lets agents enumerate your APIs from the domain alone, served on every build surface with its registered application/linkset+json media type and the RFC’s profile="https://www.rfc-editor.org/info/rfc9727" parameter. There’s nothing to configure: the catalog is derived from what’s already in blume.config.ts. Each OpenAPI, AsyncAPI, or GraphQL reference becomes an entry anchored at its rendered docs route, with service-doc pointing at those docs and service-desc at the spec when it lives at a fetchable URL; the site’s own JSON API becomes an entry described by its /openapi.json; and the MCP server becomes an entry with its discovery document as the service description:
{
"linkset": [
{
"anchor": "https://docs.example.com/reference",
"service-doc": [
{ "href": "https://docs.example.com/reference", "type": "text/html" }
],
"service-desc": [{ "href": "https://api.example.com/openapi.json" }]
},
{
"anchor": "https://docs.example.com/api/docs",
"service-desc": [
{
"href": "https://docs.example.com/openapi.json",
"type": "application/json"
}
],
"service-doc": [
{ "href": "https://docs.example.com/", "type": "text/html" }
]
},
{
"anchor": "https://docs.example.com/mcp",
"service-desc": [
{
"href": "https://docs.example.com/.well-known/mcp.json",
"type": "application/json"
}
],
"service-doc": [
{ "href": "https://docs.example.com/", "type": "text/html" }
]
}
]
}
A site with no API references, no MCP server, and the JSON API turned off emits no catalog — there’d be nothing in it. As everywhere, a public/.well-known/api-catalog file you ship yourself wins over the generated one.
AI catalog
The API catalog lists APIs. The AI catalog lists everything an agent could pick up from the site — the MCP server, each published skill, the JSON API, each rendered API reference, and llms.txt — in the format agent registries index: an AI Catalog document at /.well-known/ai-catalog.json, which is also the manifest Agentic Resource Discovery (ARD) consumers resolve. Each entry carries a domain-anchored urn:air:<host>:<namespace>:<name> identifier, a display name, a one-line description, the artifact’s media type, its URL, and a handful of representativeQueries — sample questions the resource can answer, which registries embed for semantic search. Here’s the catalog for a site titled “Acme” with the MCP server on and one published skill:
{
"specVersion": "1.0",
"host": {
"displayName": "Acme",
"identifier": "did:web:docs.example.com",
"documentationUrl": "https://docs.example.com/"
},
"entries": [
{
"identifier": "urn:air:docs.example.com:mcp:acme",
"displayName": "Acme",
"description": "Model Context Protocol server over the Acme documentation: full-text search, page Markdown, the page index, and the navigation tree.",
"type": "application/mcp-server-card+json",
"url": "https://docs.example.com/.well-known/mcp/server-card.json",
"capabilities": [
"search_docs",
"get_page",
"list_pages",
"get_navigation"
],
"representativeQueries": [
"search the Acme documentation",
"get a page of the Acme docs as Markdown",
"list every page in the Acme docs"
]
},
{
"identifier": "urn:air:docs.example.com:skill:acme",
"displayName": "acme",
"description": "Set up an Acme project and call its API.",
"type": "application/agent-skills+md",
"url": "https://docs.example.com/.well-known/agent-skills/acme/SKILL.md",
"representativeQueries": [
"load the acme agent skill",
"how do I use acme"
]
},
{
"identifier": "urn:air:docs.example.com:api:docs",
"displayName": "Acme docs API",
"description": "REST API over the Acme documentation: the page index, each page as JSON or Markdown, and the navigation tree, described by this OpenAPI document.",
"type": "application/vnd.oai.openapi+json",
"url": "https://docs.example.com/openapi.json",
"representativeQueries": [
"fetch a page of the Acme docs as JSON",
"list the pages in the Acme docs",
"get the navigation tree of the Acme docs"
]
},
{
"identifier": "urn:air:docs.example.com:docs:llms-txt",
"displayName": "Acme llms.txt",
"description": "llms.txt index of the Acme documentation: every page with a one-line summary, plus the agent-facing resources on this site.",
"type": "text/plain",
"url": "https://docs.example.com/llms.txt",
"representativeQueries": [
"what is Acme",
"overview of the Acme documentation"
]
}
]
}
ARD’s current revision reads the manifest from /.well-known/ard.json and calls ai-catalog.json the predecessor path, so Blume writes the same document to both, advertises it under both link relations (ai-catalog and ard) in every page’s head, and lists it in llms.txt and agent-readability.json. The catalog and its .well-known neighbors (the API catalog, the MCP discovery files) are served with Access-Control-Allow-Origin: * on every build surface, so a registry reading them from another origin isn’t blocked.
Entry identifiers are anchored on your domain, so the catalog needs a deployment.site — without one nothing is emitted. It’s on by default; agents.catalog: false turns it off. The generated queries are derived from the site title and each entry’s own name. To write your own for an entry, key them by the identifier’s tail (<namespace>:<name>):
export default defineConfig({
agents: {
catalog: {
queries: {
"mcp:acme": ["how do I install Acme", "search the Acme docs"],
"skill:acme": ["set up an Acme project", "write an Acme plugin"],
},
},
},
});
A list replaces the generated queries for that entry; entries you don’t name keep theirs. As everywhere, a public/.well-known/ai-catalog.json (or ard.json) you ship yourself wins over the generated one.
WebMCP
WebMCP is an emerging browser API that lets a page register tools directly with an agentic browser — no separate server connection needed. Every Blume page registers the docs’ read-only surface on the page’s model context: search_docs (site search), get_page (a page’s raw Markdown), and list_pages (the llms.txt index). The script is tiny, loads no search machinery until a tool is actually called, and silently no-ops in every browser without the API — which today is all of them outside Chrome’s early preview. It registers on whichever surface the in-flux spec exposes (navigator.modelContext or document.modelContext), via provideContext or per-tool registerTool.
It’s on by default; set webmcp: false to opt out:
agents: {
webmcp: false,
}
Your site’s skill
Every build writes an agent skill for your docs, a SKILL.md a coding agent can install to work with your product. It’s served at /skill.md and listed in the skills index. Blume builds it from what it already knows about the site, without calling a model, so it costs nothing and changes whenever your docs do. It holds:
- the site’s title and description, and when an agent should use the skill
- how to read any page as Markdown, plus
llms.txt,llms-full.txt, the MCP server, the JSON API, and the changelog, when the site serves them - your API references
- a map of the docs in sidebar order, linking each page’s Markdown with its one-line description (its meta description, as in
llms.txt)
The skill is named after your site’s title (Acme Docs becomes acme-docs). It covers the current version in the default language, leaves out API operation pages and changelog entries (each has one line of its own above the map), skips the pages llms.txt does (drafts, hidden and noindex pages, and pages with ai.exclude), and lists up to 200 pages, pointing at llms.txt for the rest. The links are absolute, so the skill needs deployment.site; a build without one skips it.
The generated skill is a guide to your docs, not a summary of your product. For one that teaches an agent your product — setup, core concepts, common tasks, and gotchas — have your coding agent write it from the docs:
blume skill --claude # or --codex
blume skill opens Claude Code or Codex on the blume-write-skill skill, which writes a SKILL.md under your agents.skills folder (./skills when it isn’t set, adding it to your config), named after your site so it replaces the generated one. You review and commit it like any other file; it costs your agent’s tokens once, not on every build. Run it again after a big docs change, since a hand-written skill doesn’t update itself. Without a flag, blume skill prints where the skill would go and how to point another agent at it.
Any skill of the same name in agents.skills, however you write it, replaces the generated one in the index, and at /skill.md too when it’s a lone SKILL.md. A public/skill.md you ship wins over both. To turn the skill off:
agents: {
skillMd: false,
}
Skills discovery
If your project ships agent skills — the Blume repo itself does — point agents.skills at the directory that holds them, and the build publishes them for discovery per the Agent Skills Discovery RFC:
agents: {
skills: "./skills",
}
The path resolves against your project root, and each subdirectory with a SKILL.md becomes a published skill. A skill that’s a lone SKILL.md is copied verbatim to /.well-known/agent-skills/<name>/SKILL.md (type: "skill-md"); a skill with supporting resources (scripts/, references/, assets/) is bundled into a deterministic .tar.gz (type: "archive") so its relative references resolve after unpacking, with script execute bits preserved. The discovery index at /.well-known/agent-skills/index.json carries the v0.2.0 $schema and, per skill, its name, type, description (from the SKILL.md frontmatter), artifact URL, and the SHA-256 digest clients verify downloads against.
Skills with a missing or spec-invalid name/description are skipped with a build warning rather than published broken, and a public/.well-known/agent-skills/index.json you ship yourself takes over the whole surface. Published skills, your site’s own included, are also listed in llms.txt.
DNS-based discovery (DNS-AID)
DNS for AI Discovery is an emerging IETF draft that lets agents discover a site’s AI surface before making a single HTTP request, by querying ServiceMode SVCB/HTTPS records at a well-known DNS entrypoint. DNS records live in your zone, not in the build, so this is the one discovery surface Blume can’t publish for you — instead, add a record with your DNS provider:
_index._agents.docs.example.com. 3600 IN HTTPS 1 docs.example.com. alpn=h2
Use the HTTPS record type if your provider offers it (Vercel DNS does; it doesn’t support the plain SVCB type), or a ServiceMode SVCB record with alpn and port parameters otherwise. The draft also recommends signing the zone with DNSSEC so validating resolvers return authenticated answers — providers like Cloudflare enable it in one click, while some (including Vercel DNS) don’t support it at all.
blume audit --url <origin> checks this for you: when deployment.site is set, the network tier queries the entrypoint over DNS-over-HTTPS and reports the exact record to publish if none exists, plus whether the answers are DNSSEC-authenticated. Set BLUME_DOH_URL to point the lookup at your own resolver if your network blocks the public ones (Google, Cloudflare).
Web Bot Auth
Web Bot Auth works in the other direction: it’s not about agents reading your docs, but about your organization’s agents identifying themselves when they make requests elsewhere. Your agents sign their requests with HTTP Message Signatures, and receiving sites verify them against a public-key directory published on your domain. If your org runs agents and your Blume site lives at the domain they identify as, publish their public keys:
agents: {
webBotAuth: {
keys: [{ kty: "OKP", crv: "Ed25519", x: "JrQLj5P_89iXES9-vFgrIy29c…" }],
},
}
Blume then serves the JWKS at /.well-known/http-message-signatures-directory with its registered media type on every build surface. The directory is public by definition, so the config only admits public keys — a JWK containing private material (d, p, q, …) fails validation with an error rather than shipping a leaked credential. Generate an Ed25519 pair with:
node -e 'const { generateKeyPairSync } = require("node:crypto"); const { publicKey, privateKey } = generateKeyPairSync("ed25519"); console.log("public: ", JSON.stringify(publicKey.export({ format: "jwk" }))); console.log("private:", JSON.stringify(privateKey.export({ format: "jwk" })))'
The public JWK goes in the config above; the private one goes wherever your signing agent runs (a secret manager, never the repo). If your organization doesn’t operate agents, skip this — an empty directory advertises nothing worth verifying.
Since blume.config.ts is executed at build time, the key doesn’t have to be hardcoded — load it from a build-time environment variable to keep the config free of key blobs and rotate without a commit:
const webBotAuthKey = process.env.WEB_BOT_AUTH_PUBLIC_JWK;
export default defineConfig({
agents: {
webBotAuth: {
keys: webBotAuthKey ? [JSON.parse(webBotAuthKey)] : [],
},
},
});
Environments without the variable publish no directory, and a key loaded this way is validated exactly like an inline one — including the private-material check. (The public key isn’t a secret, so committing it inline is equally fine; the env var is an ergonomic choice, not a security one.)