Markdown for agents
Every page's raw Markdown at a .md URL or via Accept content negotiation, custom serializers for your own components, and the Copy as Markdown and Open in chat actions readers get for free.
HTML is for browsers. Agents and LLMs do better with the Markdown your pages are written in — fewer tokens, no chrome, and components rendered in a form a model can read. Blume serves that Markdown for every page, in dev and production, with no configuration.
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.
| 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, <Card> its title as a link over its body (and <CardGroup> the cards it holds), and <YouTube> a link. The components a generated API reference page is made of downlevel too: <Operation> becomes the endpoint in its spec’s own notation (GET /pets/{id}, SEND user/signup, query pets) with a deprecation marker, <ApiTagOperations> a list of those endpoints linked to their pages with their summaries, and <ApiOverview> the API’s version and base URLs — so an agent reading a reference page knows what to call, and site search matches an endpoint’s path. 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 or Cloudflare server build 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 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 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 advertises contentNegotiation only on deployments that honor the header.
Missing pages negotiate too. Every build emits a Markdown 404 page at /404.md — the not-found message followed by recovery links to every top-level section, the sitemap, and llms.txt — and on Vercel a request for a nonexistent URL that prefers Markdown, or any .md URL with no page behind it, gets that body with a real 404 status rather than the HTML shell.
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 }) =>
``;
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 — and childBlocks() returns every direct child in order, components and prose alike, each already downleveled to a block of Markdown (the built-in <CardGroup> serializer is just those blocks joined by blank lines). 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.
Where the Clipboard API is unavailable or the browser denies it — in-app browsers, WebViews, insecure origins — the action falls back to the legacy copy command, and if nothing lands on the clipboard the button reports Copy failed (localized via actions.copyFailed) rather than staying silent. The same fallback backs every copy button Blume renders.
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.mdso 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.
The prompt is part of the UI dictionary (actions.openInChatPrompt), so localized sites send it in their language, and i18n.ui can override the wording — keep the {url} placeholder, which is replaced with the page’s raw-Markdown URL.
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:
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, which renders a labeled row with a Copy prompt button and an optional open-in-Cursor link.