This site now speaks Markdown to agents
Try this:
curl -s -H "Accept: text/markdown" https://www.klokie.com/about/
You get the page as Markdown. Drop the header and you get the same URL as HTML, the way a browser would. Same address, two representations — which is just content negotiation, a part of HTTP that has been sitting there unused since 1997 and suddenly has a job again.
The job is AI agents. When something fetches a page to read it, HTML is mostly
overhead: nav, scripts, style tags, layout wrappers, a cookie banner. The
convention forming around this is
acceptmarkdown.com — send Accept: text/markdown,
get Markdown back. It costs a fraction of the bytes and the reader spends its
attention on the prose instead of the <div>s.
The whole thing is four rules
- Serve Markdown when it’s asked for, with
Content-Type: text/markdown; charset=utf-8. - Set
Vary: Accepton every negotiated response, HTML included. Without it a CDN caches whichever variant arrived first and then serves it to everyone — a wall of Markdown to browsers, or<div>soup to agents. - Return
406when the client genuinely can’t be satisfied — and only then. This is where people get it wrong in the other direction. - Honor q-values.
Accept: text/markdown, text/html;q=0.8means “Markdown please, HTML if you must.”
That fourth one is why you can’t implement this with
accept.includes("text/markdown"). Chrome sends
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8.
A substring check happens to work; the moment you handle q=0 — which
explicitly means don’t send me this — you need a real parser that sorts by
quality and breaks ties by specificity.
My setup, which is boring on purpose
This site is written in Markdown in an Obsidian vault, built with Astro, and
served from Cloudflare Workers. So the “Markdown representation” of a page
isn’t a conversion of anything — it’s the source. The build emits an
index.md next to every index.html, and a small worker in front picks one:
const chosen = preferredType(request.headers.get("accept"), [
"text/html",
"text/markdown",
]);
If it picks Markdown, the worker fetches the .md sibling instead. Either way
it appends Vary: Accept, and HTML responses also advertise the sibling with
Link: </about/index.md>; rel="alternate"; type="text/markdown" for clients
that look. On Cloudflare the one config that matters is run_worker_first —
without it, Workers Assets answers straight from the bucket and your
negotiation code never runs at all.
I also added a /llms.txt: a map of the
site with a “when to use this” section that says plainly what this site can
answer and what it can’t. If an agent is going to summarize me, it may as well
have my own description to work from.
Two things that bit me
A 404 is a conversation. A dead URL returns 404 either way, but the body
is negotiated too: browsers get the designed page, and a client that never
asked for HTML gets a short Markdown body with links to /llms.txt, the
sitemap, and the main sections. An agent that hits a broken link shouldn’t
have to guess where to go next — and a 404 page is the one place where
“default to HTML” protects nobody.
Accept: */* is not a request for Markdown. It means “anything’s fine,”
so it gets the default: HTML. That’s the spec, and it’s also just correct —
curl sends */* and nobody typing curl wants a surprise.
And one that had nothing to do with HTTP. My verification script kept reporting a file as missing when it plainly wasn’t:
printf '%s' "$body" | grep -q "# About" # lies on large inputs
grep -q exits the moment it matches, which SIGPIPEs the printf still
writing 150 KB into the pipe, which set -o pipefail then reports as a failed
pipeline. The fix is to let grep read the whole stream:
grep -F -- "$needle" >/dev/null. I lost fifteen minutes to a test that was
failing because it had succeeded too quickly.
The full check suite runs against the live site after every deploy — 42 of them, covering both directions of every q-value case, because “serves Markdown correctly” and “accidentally serves Markdown to Chrome” look identical until you test for the second one.