Over the weekend, I was checking Is Your Site Agent Ready? by Cloudflare, and one of the recommendations is to set up content negotiation to allow agents to consume Markdown instead of HTML.
I recently wrote about serving Markdown to LLMs in Eleventy. We’ll build upon it for content negotiation, so if you haven’t read the first article, now is a good time.
If you just want to get the code for the Netlify function, head to the repository.
Content negotiation
Content negotiation is a mechanism for serving different representations of the same resource at the same URL depending on the context. It’s not a novel idea, and is used all over the place, from image formats to internationalization purposes.
When applied to AI, there is this idea that LLMs could be requesting Markdown by sending an Accept: text/markdown HTTP header, and servers would return a Markdown response instead of the usual HTML resource. This should significantly reduce token consumption, at least in theory.
On Netlify
I’m hosting this site (and a few others) on Netlify, so this is what we’re going to be looking at today. While the core concept is likely transposable to other hosting platforms, the APIs we’ll be using are specific to Netlify.
Namely, we’ll be using Netlify Edge Functions, which are essentially middlewares written in JavaScript or TypeScript and running on Deno (which is similar to Node.js).
To get started, we’ll create netlify/edge-functions/markdown-negotiation.ts. An edge function needs 2 parts: a config object determining when the function should be executed, and a handler defining what should happen.
Configuration
Let’s start with the configuration. There are quite a few available options. We won’t use them all.
import type { Config } from '@netlify/edge-functions'
export const config: Config = {
method: 'GET',
header: { accept: 'text/markdown' },
pattern: [
String.raw</span><span class="token string">^/\d{4}/\d{2}/\d{2}/[^/]+/?$</span><span class="token template-punctuation string">,
String.raw</span><span class="token string">^/\d{4}/\d{2}/\d{2}/[^/]+/index\.html$</span><span class="token template-punctuation string">,
String.raw</span><span class="token string">^/\d{4}/\d{2}/\d{2}/[^/]+/index\.md$</span><span class="token template-punctuation string">,
],
}
We explicitly define 3 requirements for our function to run:
- It should be a
GETrequest. No other method will cause the function to run. - It should have the
AcceptHTTP header and its value should containtext/markdown, which is kinda the whole point. Note that this is not a strict equality check, it’s more akin to a regular expression. - It should be requesting a blog post (which uses the
/YYYY/MM/DD/slugformat), any variant of it. Either the extension-less path (with or without a trailing slash), or the HTML file, or the Markdown file.
Core logic
Before looking at the code, let’s walk through what the function should actually do. If requesting the HTML version of a blog post with an Accept header indicating it prefers Markdown, it should serve the Markdown version of the post.
In other words, and a bit more broken down:
- Make sure we’re not already requesting the
.mdfile. - Make sure we actually prefer Markdown for this request.
- Find the Markdown counterpart of the requested URL.
- Return this Markdown response with the appropriate headers.
This is the shell of our function:
export default async function markdownNegotiation(
request: Request,
context: Context,
): Promise<Response | undefined> {
const url = new URL(request.url)
const { pathname } = url
// Our logic goes here …
return new Response(body, { status, headers })
}
Let’s fill in the blanks. First, we want to return early if the Markdown file is being requested, since there is nothing to do:
if (pathname.toLowerCase().endsWith('.md')) {
return context.next()
}
Then, we want to make sure the Markdown version is actually preferred. This step is not entirely necessary, it’s just to be more accurate. The Accept HTTP header has a concept of weights to indicate which media type is really preferred, so you’d typically want to parse it to know which format is expected. The @hapi/accept package does that neatly.
import Accept from '@hapi/accept'
const acceptHeader = request.headers.get('accept')
const acceptableTypes = ['text/html', 'text/markdown']
if (Accept.mediaType(acceptHeader, acceptableTypes) !== 'text/markdown') return
If we should return Markdown, the next step is to figure out the path to the Markdown counterpart of this post. I’ve created this getMarkdownTwin function for that:
function getMarkdownTwin(pathname: string): string | null {
const HTML_PATH_RE = /^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)\/index\.html$/i
const EXTLESS_PATH_RE = /^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)\/?$/
// Test the HTML path first
let m = pathname.match(HTML_PATH_RE)
// If it fails, test the extension-less path
if (!m?.[1] || !m[2] || !m[3] || !m[4]) {
m = pathname.match(EXTLESS_PATH_RE)
// If it fails, give up
if (!m?.[1] || !m[2] || !m[3] || !m[4]) return null
}
const [, year, month, day, slug] = m
return ['', year, month, day, slug, 'index.md'].join('/')
}
And if we cannot figure out the path to the Markdown file, give up. Note that this does not check the existence of a resource at that path, just that this matches a blog post URL for which we assume there is a Markdown version.
const markdownTwinPath = getMarkdownTwin(pathname)
if (!markdownTwinPath) return
Once we know where to look, we can perform a request to retrieve the content of the Markdown version. Netlify discourages same-site fetch calls from the Edge, so we let it load the file statically.
Note that we update the Accept HTTP header to */* to avoid negotiating again on the inner pass (I’m actually not sure whether Netlify would execute our function here, but better safe than sorry).
If we cannot find a Markdown file at that location (or hit any sort of error), we just pass through and let the normal flow continue: serve the HTML resource.
const innerUrl = new URL(markdownTwinPath, url.origin).toString()
const innerHeaders = new Headers(request.headers)
innerHeaders.delete('accept')
innerHeaders.set('accept', '*/*')
const upstream = await context.next(
new Request(innerUrl, {
method: request.method,
headers: innerHeaders,
redirect: 'manual',
}),
)
if (!upstream.ok || upstream.status === 404) {
return context.next(request)
}
Now that we have our Markdown file, we can return its content. We want to adjust a few HTTP headers though:
Content-Typecan be adjusted to reflect that we are serving Markdown.Vary: acceptcan be set to indicate that the resource was modified based on theAcceptheader.Content-Lengthcan be adjusted to reflect the length of the Markdown response (instead of the length of the original HTML response).X-Markdown-Tokenscan be specified to indicate how many tokens are likely consumed by this response. This is optional, just to follow the Cloudflare way.Transfer-Encodingneeds to be dropped since we modified the response and its content length.
const headers = new Headers(upstream.headers)
headers.set('content-type', 'text/markdown; charset=utf-8')
headers.set('vary', 'accept')
const text = await upstream.text()
const body = new TextEncoder().encode(text)
headers.set('content-length', String(body.byteLength))
headers.set('x-markdown-tokens', String(estimateTokens(body.byteLength)))
headers.delete('transfer-encoding')
return new Response(body, { status: upstream.status, headers })
And there you have it! You can look at the complete version of the code on GitHub, including generous comments.
Testing it
We can issue a curl request with the relevant Accept header and look at the headers we get back. Picking any article at random:
URL="https://kittygiraudel.com/2026/04/13/play-sound-on-claude-idle/"
No Accept header:
curl -sS -D - "$URL" \
-o /dev/null | tr -d '\r' | grep -iE '^(HTTP|content-type|content-length|vary|x-markdown)'
HTTP/2 200
content-type: text/html; charset=UTF-8
content-length: 33756
Accept header with HTML preference:
curl -sS -D - "$URL" \
-H "Accept: text/html;q=1, text/markdown;q=0.5" \
-o /dev/null | tr -d '\r' | grep -iE '^(HTTP|content-type|content-length|vary|x-markdown)'
HTTP/2 200
content-type: text/html; charset=UTF-8
content-length: 33756
Accept header with Markdown preference:
curl -sS -D - "$URL" \
-H "Accept: text/markdown;q=1, text/html;q=0.5" \
-o /dev/null | tr -d '\r' | grep -iE '^(HTTP|content-type|content-length|vary|x-markdown)'
HTTP/2 200
content-type: text/markdown; charset=utf-8
vary: Accept-Encoding, accept
x-markdown-tokens: 428
Accept header with Markdown preference on a resource without a Markdown version:
curl -sS -D - "https://kittygiraudel.com/" \
-H "Accept: text/markdown;q=0.5, text/html;q=0.1" \
-o /dev/null | tr -d '\r' | grep -iE '^(HTTP|content-type|content-length|vary|x-markdown)'
HTTP/2 200
content-type: text/html; charset=UTF-8
content-length: 25130
Wrapping up
As mentioned in my previous article about the topic, I have somewhat mixed feelings about the whole thing, because we end up having to optimize our websites to make it easier for machines to steal consume our content. Maybe AEO is just the new SEO/performance.
Nevertheless, it was a good learning opportunity since I had never used Netlify edge functions before, so this was fun.
Anyway, I hope it helps.