---
title: AI
description: Make your docs machine-readable with llms.txt, add an optional in-page Ask AI assistant, and expose a hosted MCP server for coding agents.
---

Blume has a few AI features: machine-readable docs for external tools (`llms.txt`, on by default), an in-page **Ask AI** assistant, and a hosted **MCP server** for coding agents. Ask AI and MCP are opt-in, and static docs stay fully static until you turn a feature on.

## llms.txt

Blume emits machine-readable versions of your docs that coding agents and chat assistants can consume. This is on by default; set `llmsTxt: false` to turn it off:

```ts blume.config.ts lineNumbers
ai: {
  llmsTxt: false,
}
```

While enabled, `blume build` writes two files to the root of your site:

- **`/llms.txt`** — a compact index: your site title and description, then a linked list of every page with its summary, organized into sections that mirror your sidebar — folders and groups become headings, so an agent sees the docs' structure, not one flat blob.
- **`/llms-full.txt`** — the entire corpus: each page's full Markdown body, with its source URL, in one file.

Draft pages are excluded. Set [`deployment.site`](/docs/deployment) so the links and source URLs resolve to absolute addresses.

`llmsTxt` also takes an object form with knobs for what the files include. If your [API reference](/docs/advanced/api-reference) documents a placeholder or example spec, set `openapi: false` to keep its generated pages out of both files:

```ts blume.config.ts lineNumbers
ai: {
  llmsTxt: {
    enabled: true, // default
    openapi: false, // exclude generated API reference pages
  },
}
```

To keep an individual page out of both files, set `ai.exclude` in its frontmatter:

```mdx
---
title: Internal notes
ai:
  exclude: true
---
```

The page still renders, stays in search, and keeps its place in the sitemap — only the `llms.txt` files skip it.

To take full control of either file, add your own `llms.txt` or `llms-full.txt` to your `public/` folder. Like a custom favicon, it's picked up automatically and ships in place of the generated file — override one and Blume still generates the other.

## Raw Markdown

Append `.md` or `.mdx` to any page's URL to fetch its raw Markdown source — perfect for LLMs, coding agents, and "copy as Markdown" workflows. It's available for every page, in dev and production, with no configuration.

| URL               | Returns                                   |
| ----------------- | ----------------------------------------- |
| `/quickstart`     | The rendered page                         |
| `/quickstart.md`  | Plain Markdown, with components converted |
| `/quickstart.mdx` | The raw MDX source, exactly as written    |

Nested routes work the same way (`/content/syntax.md`), and the home page is served at `/index.md`.

The `.md` variant _downlevels_ components to plain Markdown for consumers that can't interpret JSX: `<TypeTable>` becomes a Markdown table, `<Callout>` a labeled blockquote, `<Steps>` an ordered list, `<Tabs>` bold-labeled sections, and `<YouTube>` a link. Props are evaluated with the page's `frontmatter` in scope, so a prop like `title={frontmatter.status}` resolves to the same value the rendered page shows. Anything that can't be converted faithfully — a custom component, or a prop computed from an import — is left as-is, and component markup inside fenced code blocks is never touched. The same conversion applies to `llms-full.txt` and the MCP server's `get_page` tool, so every agent-facing surface reads clean Markdown. When you want the untransformed source, use the `.mdx` variant.

### Content negotiation

Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel or Cloudflare server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy automatically — routing rules on Vercel, a generated Worker on Cloudflare — no configuration needed. The homepage always negotiates, even when it's a custom landing page rather than a content page: its Markdown mirror falls back to the [`llms.txt`](#llmstxt) index, so an agent asking the site root for Markdown gets the machine-readable map of the site. Markdown responses also carry an `x-markdown-tokens` header — an estimated token count (~4 characters per token), following the convention of [Cloudflare's Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) — on every surface where Blume controls response headers: the dev server, server-rendered responses, and the negotiated homepage on Vercel and Cloudflare. Other deploy targets serve prerendered pages from a static layer with no request-time hook, so agents there fetch the `.md` URL directly; the [agent readability manifest](#agent-readability) advertises `contentNegotiation` only on deployments that honor the header.

### Custom component serializers

Give your own components a Markdown form with `ai.markdownComponents` — a map of JSX name to serializer. Each serializer receives the component's `props` (statically evaluated from the MDX attributes, with the page's `frontmatter` in scope), its `children` (already downleveled to Markdown), and the page's `frontmatter` data, and returns the replacement — or `null` to leave the JSX as-is:

```ts blume.config.ts lineNumbers
import { defineConfig } from "blume";
import type { ComponentMarkdown } from "blume";

const chart: ComponentMarkdown = ({ props }) =>
  `![${props.title}](/charts/${props.slug}.png)`;

export default defineConfig({
  ai: {
    markdownComponents: {
      Chart: chart,
    },
  },
});
```

For container components, `childComponents("Name")` extracts direct children by tag — the same way the built-in `<Steps>` serializer collects its `<Step>` items. A same-name entry replaces a built-in serializer, so you can restyle how `<Callout>` downlevels — or return `null` to opt one out entirely.

Serializers live in `blume.config.ts`, not `components.tsx`: the config file is executed at build time, while the components file is only statically analyzed (it may import `.astro` files, which can't run outside the site build). Your components themselves stay registered in `components.tsx` exactly as before — `markdownComponents` only adds their agent-facing Markdown form.

## Copy as Markdown

Every page carries a **Copy as Markdown** action — in the [page actions](/docs/content/navigation#page-actions) beneath the table of contents — that copies the page's raw Markdown to the clipboard. It's the same source served at the [`.md` URL](#raw-markdown) above, ready to paste into an LLM, an issue, or your notes. It's available on every page, in dev and production, with no configuration.

## Open in chat

The **Open in chat** action opens the current page in an AI assistant — v0, ChatGPT, Claude, T3 Chat, Scira, or Cursor — pre-filled with a prompt that points it at the page's raw Markdown so it can answer questions about what you're reading:

> Read `https://your-site/this-page.md` so I can ask you questions about this page.

Like Copy as Markdown, it needs no setup. The assistant fetches the page over its public URL, so it works as soon as the page is deployed.

To tailor the action, set `ai.openInChat`. `false` hides it entirely, and an array of provider keys — `"v0"`, `"chatgpt"`, `"claude"`, `"t3"`, `"scira"`, `"cursor"` — shows just those providers, in the order you list them:

```ts blume.config.ts lineNumbers
ai: {
  openInChat: ["claude", "chatgpt", "cursor"],
}
```

To embed a ready-to-copy prompt inline in your content — rather than a whole-page action — use the [Prompt component](/docs/content/components#prompt), which renders a labeled row with a **Copy prompt** button and an optional open-in-Cursor link.

## Ask AI

Add an assistant that answers reader questions in an in-page chat panel, backed by a streaming server endpoint and the [AI SDK](https://ai-sdk.dev):

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    provider: "gateway", // default
    model: "openai/gpt-5.5",
  },
}
```

### Suggested questions

Seed the empty state with a few starter prompts. Each renders as a clickable suggestion — click one to send it — with an optional [Lucide icon](/docs/content/components#icon) beside the label:

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    suggestions: [
      { label: "What is Blume?", icon: "rocket" },
      { label: "How do I write a docs page?", icon: "file-text" },
      { label: "How do I configure the theme?", icon: "settings" },
    ],
  },
}
```

`label` is the question that gets asked; `icon` is optional. Leave `suggestions` unset (or empty) and the panel opens to a plain input.

### Custom instructions

Add your own system-prompt text with `instructions` — identity, language, tone, or anything else the assistant should keep in mind:

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    instructions:
      "You are Bloomy, the Acme docs assistant. Answer in the language the question was asked in, and keep answers under three paragraphs.",
  },
}
```

Your text is **appended to** the built-in instructions rather than replacing them: the built-in part carries the [grounding](#grounding) contract — answer only from the retrieved pages, cite them as Markdown links — that the chat panel's citations depend on, so it stays intact whatever you add.

### Grounding

Ask AI is **grounded in your docs**. For each question it retrieves the most relevant pages — using the same lexical [Orama](/docs/configuration/search) index that powers on-page search — and injects them into the model's system prompt, so answers come from your content instead of the model's own knowledge. The assistant is told to answer only from the retrieved pages, to say when something isn't covered, and to cite the pages it drew from.

The page the reader is currently on is added to the context first and used to scope retrieval to that page's language, so answers stay relevant to where they are in the docs. Retrieval runs at request time from a snapshot baked into the build, so it works regardless of your [search](/docs/configuration/search) provider — even when search is set to `none` — and needs no configuration.

Grounding is on for every backend except **[Inkeep](#backends)**, which runs its own retrieval over the content you've indexed in its dashboard.

### Retrieval size

How much documentation a question carries is the biggest lever on how long the reader waits for the first word: the model reads every injected character before it emits a token. On a hosted frontier model that's invisible, but on a self-hosted backend it dominates. `retrieval` sizes it:

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    retrieval: {
      maxResults: 3, // fewer pages retrieved per question
      excerptChars: 1200, // shorter excerpt from each one
      contextBudget: 3000, // smaller total injection
    },
  },
}
```

| Option          | Default | Description                                     |
| --------------- | ------- | ----------------------------------------------- |
| `maxResults`    | `6`     | Documents retrieved per question.               |
| `excerptChars`  | `2000`  | Characters kept from each retrieved page.       |
| `contextBudget` | `10000` | Total injected characters, across all excerpts. |

The three aren't interchangeable. `contextBudget` caps the whole injection, `excerptChars` decides how deep into a single long page its excerpt reaches — raise it when one page holds the whole answer and the excerpt cuts it off — and `maxResults` caps how many pages retrieval adds. The page the reader is viewing is injected on top of the retrieved ones, so an answer can cite up to one page more than `maxResults`.

The defaults suit a hosted model. Lower them when you're serving from your own hardware and time-to-first-token matters more than recall; answers stay grounded either way, and the assistant is told to say when something isn't covered rather than fill the gap.

### External endpoint

Already have an API backend for AI? Point the panel at it and keep the docs build static:

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    endpoint: "https://api.example.com/v1/docs/ask",
  },
}
```

Blume sends the same `POST` body as its built-in route:

```json
{
  "messages": [{ "role": "user", "content": "How do I deploy?" }],
  "page": { "path": "/deployment" }
}
```

Return a successful response whose body is a plain UTF-8 text stream. If the endpoint is on another origin, allow the docs origin with CORS: accept `OPTIONS` and `POST`, permit the `content-type` request header, and return the CORS headers on both the preflight and streamed response. With `endpoint` set, Blume generates the chat UI but no server route, grounding snapshot, provider dependency, or provider-secret warning; your backend owns retrieval, authentication, rate limiting, model access, and citations.

### Server output required

Blume's built-in Ask AI backend is a server route (`POST /api/ask`), so it can't run on a static build. Switch to server output and pick an adapter:

```ts blume.config.ts lineNumbers
deployment: {
  output: "server",
  adapter: "vercel",
}
```

A static build with Ask AI enabled and no external `endpoint` fails fast with a message telling you to set `deployment.output` to `server`. See [Deployment](/docs/deployment) for the adapters.

### Backends

By default Ask AI routes through the **Vercel AI Gateway**: `model` is a `provider/model` string, so you switch models by changing it (`openai/gpt-5.5`, `anthropic/claude-sonnet-4-5`, and so on) with no provider SDK to install. The gateway reads `AI_GATEWAY_API_KEY` from your environment and is wired up automatically when you deploy on Vercel.

Set `provider` to point Ask AI somewhere else. Each backend reads its API key from an environment variable and streams through a provider SDK you install in your project — only the one you use:

| `provider` | `model` | API key env var | SDK to install |
| --- | --- | --- | --- |
| `gateway` (default) | a `provider/model` string via the AI Gateway | `AI_GATEWAY_API_KEY` | none — ships with Blume |
| `openrouter` | any [OpenRouter](https://openrouter.ai) model | `OPENROUTER_API_KEY` | `@openrouter/ai-sdk-provider` |
| `llmgateway` | any [LLMGateway](https://llmgateway.io) model | `LLMGATEWAY_API_KEY` | `@ai-sdk/openai-compatible` |
| `inkeep` | an [Inkeep](https://inkeep.com) QA model | `INKEEP_API_KEY` | `@ai-sdk/openai-compatible` |
| `openai-compatible` | whatever your endpoint serves | set with `apiKeyEnv` | `@ai-sdk/openai-compatible` |

The SDKs are optional peer dependencies, so add the one your backend needs to your project (e.g. `npm install @openrouter/ai-sdk-provider`). If it's missing, the build warns with the exact package name before Vite would fail to resolve the import.

For example, to use OpenRouter:

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    provider: "openrouter",
    model: "anthropic/claude-sonnet-4-5",
  },
}
```

Any OpenAI-compatible endpoint works through `openai-compatible` — supply the `baseUrl` and the env var holding its key:

```ts blume.config.ts lineNumbers
ai: {
  ask: {
    enabled: true,
    provider: "openai-compatible",
    baseUrl: "https://my-gateway.example.com/v1",
    apiKeyEnv: "MY_GATEWAY_API_KEY",
    model: "gpt-4o",
  },
}
```

Set `apiKeyEnv` (and, for the named providers, `baseUrl`) on any backend to point at a different env var or proxy.

:::note
**Inkeep** answers from the content you've indexed in the Inkeep dashboard — it runs its own retrieval — so Blume leaves it ungrounded. Every other backend is [grounded](#grounding) in this site's pages.
:::

Keys are read with `process.env`, which covers the Node, Vercel, and Netlify adapters. On Cloudflare, expose the key through the platform's [runtime binding](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#environment-variables-and-secrets). Enabling Ask AI also turns on React for the in-page island — see [Customization](/docs/configuration/customization#interactive-islands).

### Rate limiting

The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request — rejecting malformed bodies, capping it to 1–40 messages, and accepting only `user`/`assistant` roles so a caller can't inject their own system prompt and repurpose the route as a general LLM proxy — to bound how much a single call can spend against your model, but it can't stop someone from calling the endpoint repeatedly. If cost abuse is a concern, put the route behind a rate limiter — your host's (e.g. Vercel's) edge rate limiting, a middleware, or your model provider's per-key spend limits.

## MCP server

Host a [Model Context Protocol](https://modelcontextprotocol.io) server so coding agents (Claude Code, Cursor, VS Code, claude.ai connectors) can search and read your docs directly — no scraping:

```ts blume.config.ts lineNumbers
ai: {
  mcp: {
    enabled: true,
    route: "/mcp", // where the server is mounted
  },
}
```

| Option         | Default | Description                                       |
| -------------- | ------- | ------------------------------------------------- |
| `enabled`      | `false` | Generate and host the MCP server.                 |
| `route`        | `/mcp`  | Path the Streamable-HTTP endpoint is mounted on.  |
| `name`         | title   | Server name shown to clients (defaults to title). |
| `instructions` | —       | Optional system hint passed to connecting agents. |

The server exposes read-only tools — `search_docs`, `get_page`, `list_pages`, and `get_navigation` — and publishes discovery documents at `/.well-known/mcp.json` and `/.well-known/mcp/server-card.json`. The server card follows the [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) Server Card extension schema (reverse-DNS `name`, `remotes` transport endpoints), with initialize-shaped compat fields (`serverInfo`, `capabilities`, `transports`) for scanners built against the proposal's earlier revision. Each page's **Connect to MCP** menu offers copy-and-go install for Claude Code, Cursor, VS Code, and Codex (shown once [`deployment.site`](/docs/deployment) is set).

`search_docs` runs its own full-text index, so it works regardless of your [search](/docs/configuration/search) provider — and even when search is set to `none`. The MCP server is a separate feature from on-page search.

`search_docs` and `list_pages` both accept an optional `contentTypes` filter, narrowing results to pages of the given frontmatter [`type`s](/docs/reference/frontmatter) — `["rfc"]`, `["blog", "changelog"]` — so an agent working against a site that mixes docs with RFCs, runbooks, or policies can scope retrieval to the kind of page it needs. Every result names its content type, and `list_pages` output shows the types in use.

Both tools also accept a `filters` object matching against the facets a site declares per content type ([`content.types.<type>.facets`](/docs/configuration#frontmatter)) — custom frontmatter keys whose values become filterable metadata:

```json
{
  "query": "OpenAPI request schemas",
  "contentTypes": ["rfc"],
  "filters": { "domain": "architecture", "status": "enforced" }
}
```

Every `filters` entry must match (results carry their facet values, and `list_pages` shows each page's), so a knowledge base can drive progressive-disclosure agent workflows — enumerate the enforced standards, search only within them — without any server of its own.

### Server output required

The MCP server is a live endpoint (`/mcp`), so it can't run on a static build. Switch to server output and pick an adapter:

```ts blume.config.ts lineNumbers
deployment: {
  output: "server",
  adapter: "node", // or "vercel" | "netlify" | "cloudflare"
  site: "https://docs.example.com",
}
```

A static build with `ai.mcp.enabled` fails fast with a message telling you to set `deployment.output` to `server`. See [Deployment](/docs/deployment) for the adapters. Once deployed, connect from Claude Code with:

```bash
claude mcp add --transport http my-docs https://docs.example.com/mcp
```

## Agent readability

Blume writes an **`/agent-readability.json`** manifest at your site root that indexes the agent-facing surface described on this page — 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:

```ts blume.config.ts lineNumbers
seo: {
  agentReadability: true,
}
```

The manifest lists only what you've enabled — the [raw Markdown](#raw-markdown) mirror pattern, [`llms.txt`](#llmstxt) and `llms-full.txt`, the [MCP server](#mcp-server) and its discovery document, the [Ask AI](#ask-ai) endpoint, the [sitemap](/docs/configuration/seo#sitemap), and [RSS feeds](/docs/configuration/seo#rss-feeds) — alongside your site name, description, source repository, and the [content-signal](/docs/configuration/seo#content-signals) usage policy. URLs are absolute when [`deployment.site`](/docs/deployment) is set and root-relative otherwise:

```json agent-readability.json
{
  "artifacts": {
    "markdown": {
      "contentNegotiation": "text/markdown",
      "pattern": "https://docs.example.com/{route}.md"
    },
    "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@1.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](#content-negotiation); on every other deployment the manifest advertises just the `.md` mirror pattern.

Set `seo.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](https://www.rfc-editor.org/rfc/rfc8288) `Link` response header on the homepage, using IANA-registered relation types:

```http
Link: </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](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules.

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:

```html
<link
  rel="describedby"
  href="/agent-readability.json"
  type="application/json"
/>
<link rel="describedby" href="/llms.txt" type="text/plain" />
<link rel="alternate" href="/docs/example.md" type="text/markdown" />
```

Here the `alternate` link points at _that page's own_ [raw-Markdown mirror](#raw-markdown), 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](https://www.rfc-editor.org/rfc/rfc9727) API catalog at `/.well-known/api-catalog` — a [linkset](https://www.rfc-editor.org/rfc/rfc9264) that lets agents enumerate your APIs from the domain alone, served with its registered `application/linkset+json` media type on every build surface. There's nothing to configure: the catalog is derived from what's already in `blume.config.ts`. Each [OpenAPI or AsyncAPI reference](/docs/advanced/api-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 [MCP server](#mcp-server) becomes an entry with its discovery document as the service description:

```json .well-known/api-catalog
{
  "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/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 and no MCP server 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.

### WebMCP

[WebMCP](https://webmachinelearning.github.io/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](#raw-markdown)), and `list_pages` (the [`llms.txt`](#llmstxt) 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](https://developer.chrome.com/blog/webmcp-epp). 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:

```ts blume.config.ts lineNumbers
ai: {
  webmcp: false,
}
```

### Skills discovery

If your project ships [agent skills](https://agentskills.io) — the [Blume repo itself does](#agent-skill) — point `ai.skills` at the directory that holds them, and the build publishes them for discovery per the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc):

```ts blume.config.ts lineNumbers
ai: {
  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.

### DNS-based discovery (DNS-AID)

[DNS for AI Discovery](https://datatracker.ietf.org/doc/draft-mozleywilliams-dnsop-dnsaid/) 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](https://www.rfc-editor.org/rfc/rfc9460) 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:

```txt
_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`](/docs/deployment) 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](https://datatracker.ietf.org/wg/webbotauth/about/) 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](https://www.rfc-editor.org/rfc/rfc9421), 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:

```ts blume.config.ts lineNumbers
ai: {
  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:

```bash
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:

```ts blume.config.ts lineNumbers
const webBotAuthKey = process.env.WEB_BOT_AUTH_PUBLIC_JWK;

export default defineConfig({
  ai: {
    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.)

## Agent skill

Building a Blume site with the help of a coding agent? Install the Blume [agent skill](https://docs.claude.com/en/docs/claude-code/skills) so it knows how Blume works without you explaining it:

```bash
npx skills add haydenbleasel/blume
```

The skill teaches the agent what Blume is and how to scaffold, write, and configure a site, and points it at the full docs bundled in the installed package (the `docs/` directory inside `blume`, wherever your package manager installs it).

It's one of the [agent skills Blume ships](/docs/advanced/skills), alongside a skill for keeping docs in sync with your product from a scheduled agent run.
