Skip to content
Blume is now publicly available.
Blume
Esc
navigateopen⌘Jpreview
On this page

AI

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:

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 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 documents a placeholder or example spec, set openapi: false to keep its generated pages out of both files:

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:

---
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 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 server build wires the same negotiation into the deploy’s routing rules automatically — 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 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 — on every surface where Blume controls response headers: the dev server, server-rendered responses, and the negotiated homepage on Vercel. 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 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:

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 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 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 embed a ready-to-copy prompt inline in your content — rather than a whole-page action — use the Prompt component, 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:

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 beside the label:

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.

Grounding

Ask AI is grounded in your docs. For each question it retrieves the most relevant pages — using the same lexical Orama 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 provider — even when search is set to none — and needs no configuration.

Grounding is on for every backend except Inkeep, which runs its own retrieval over the content you’ve indexed in its dashboard.

External endpoint

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

ai: {
  ask: {
    enabled: true,
    endpoint: "https://api.example.com/v1/docs/ask",
  },
}

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

{
  "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:

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 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 the matching provider SDK is added to your project’s runtime automatically when you build — only the one you use:

provider model API key env var
gateway (default) a provider/model string via the AI Gateway AI_GATEWAY_API_KEY
openrouter any OpenRouter model OPENROUTER_API_KEY
llmgateway any LLMGateway model LLMGATEWAY_API_KEY
inkeep an Inkeep QA model INKEEP_API_KEY
openai-compatible whatever your endpoint serves set with apiKeyEnv

For example, to use OpenRouter:

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:

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.

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. Enabling Ask AI also turns on React for the in-page island — see Customization.

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 server so coding agents (Claude Code, Cursor, VS Code, claude.ai connectors) can search and read your docs directly — no scraping:

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 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 is set).

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

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:

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 for the adapters. Once deployed, connect from Claude Code with:

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:

seo: {
  agentReadability: true,
}

The manifest lists only what you’ve enabled — the raw Markdown mirror pattern, llms.txt and llms-full.txt, the MCP server and its discovery document, the Ask AI 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"
    },
    "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; 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/.

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: </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. Sites that publish APIs also get a rel="api-catalog" entry pointing at the generated 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. Hosts that ignore _headers on static output (GitHub Pages, S3) can’t send custom response headers at all — there, agents still find everything through llms.txt and agent-readability.json at the site root.

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 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 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 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/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 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:

ai: {
  webmcp: false,
}

Skills discovery

If your project ships agent skills — the Blume repo itself does — point ai.skills at the directory that holds them, and the build publishes them for discovery per the Agent Skills Discovery RFC:

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

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:

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({
  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 so it knows how Blume works without you explaining it:

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, alongside a skill for keeping docs in sync with your product from a scheduled agent run.

Last updated on August 3, 2026

Was this page helpful?