Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 MDX source, components 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), <Accordion> bold questions over their answers, <FileTree> its list, <CodeGroup> its titled code blocks, <YouTube> a link, and every other built-in component — Columns, Frame, Expandable, Badge, Tooltip, and the rest — its readable content. <AutoTypeTable>, which needs the type checker, stays as written, as do a <Diff> that reads its sides from files (src, or before and after) and a <GithubInfo> without owner and repo. 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, and for an OpenAPI operation, its request body’s properties and example and its responses with their examples, <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. <Changelog /> becomes the release list it renders: year headings over a linked row per release. Props are read as literal data and never executed — strings, numbers, booleans, arrays, objects, and template strings, plus references to the page’s frontmatter, so a prop like title={frontmatter.status} resolves to the same value the rendered page shows. Only props resolve: an expression in the page’s own text, like Owned by {frontmatter.owner}, stays as written in the Markdown copy. For a value agents should read too, write it into the text, or, when it’s the same on every page, make it a content variable, which every surface replaces. Anything that can’t be converted faithfully — a custom component, or a prop computed by a function call or from an import — is left as-is, and component markup inside code, fenced or inline, 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 MDX itself, use the .mdx variant: its components stay as written, and the rest is prepared for an agent reading it by URL, as in the .md variant. Includes are spliced in, <Visibility> resolves for agents (for="web" content is removed, for="agents" content kept), relative images and links to other files beside the page point at their served URLs, relative page links and root-relative links to a content file (/guides/setup.md) point at the routes they mean, and root-relative links (/guides/install) gain the site’s deployment.base and basePath and, on a translated page, move into the page’s locale, the way the rendered page’s links do. Root-relative images and links to files in public/ (/spec.pdf) gain deployment.base alone, since that’s where those files are served.

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.

A client gets Markdown when its Accept header lists text/markdown (or text/x-markdown, in any case) with a q-value above zero and at least as high as text/html’s: text/markdown and text/markdown, text/html get Markdown, text/html, text/markdown;q=0.9 gets HTML. Vercel’s routing rules can’t compare two q-values, so when both types carry one below 1 (text/markdown;q=0.9, text/html;q=0.8), Vercel serves HTML where the dev server and Cloudflare serve Markdown. Send text/markdown without a q-value to get Markdown everywhere.

Missing pages negotiate too. The default 404 page has a Markdown twin at /404.md — the not-found message followed by recovery links to every top-level section, the sitemap, and llms.txt — and on a Vercel or Cloudflare server build a request for a nonexistent URL that prefers Markdown gets that body with a real 404 status rather than the HTML shell. On Vercel the same goes for any .md URL with no page behind it. A 404 page of your own (a pages/404.astro, or a content page at /404) replaces the default along with its /404.md twin, so missing pages then answer with your HTML page.

Custom component serializers

Give your own components a Markdown form with agents.markdownComponents — a map of JSX name to serializer. Each serializer receives the component’s props (read from the MDX attributes as literal data, with frontmatter references resolved), 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({
  agents: {
    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.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.

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.

Last updated on

Was this page helpful?