Table of contents
An AI agent that sends Accept: text/markdown to a WordPress page gets the full theme HTML back, because WordPress core does not do content negotiation for Markdown. AIScan check C1 grades exactly that request. This guide adds the second representation with one file of PHP in mu-plugins, tested on WordPress 7.1.2 and PHP 8.3.6 on 1 October 2026, so browsers keep getting HTML and agents get clean Markdown from the same URL.
Quick summary
| If you want to... | Do this | Time |
|---|---|---|
| Serve Markdown on the same URL | One must-use plugin file that answers Accept: text/markdown on posts and pages | 10 min |
| Keep caches honest | Vary: Accept on every post and page response, plus a cache rule if a CDN sits in front | 10 min |
| Skip the code | ThinkRank Pro lists Markdown for AI on the same URL as a feature | 5 min |
| Confirm it works | npx aiscan-cli yoursite.com, then read the C1 row | 1 min |
Why the URL stays the same
Check C1 re-requests a page with Accept: text/markdown and passes when the server answers text/markdown. That is proactive content negotiation as defined in RFC 9110: one URL, several representations, chosen by a request header. A /page.md sidecar file does not satisfy it, because the scanner asks the original URL. According to AIScan's own C1 guidance for WordPress (fetched from the AIScan MCP on 1 October 2026), the starter snippet serves only /index.md and removes all markup with wp_strip_all_tags, which flattens headings and links. This guide keeps both.
Step 1: add the must-use plugin
According to the must-use plugins documentation, WordPress loads every PHP file in wp-content/mu-plugins on every request with no activation step. A fresh install ships without that folder.
- Connect over SFTP or your host's file manager.
- In the WordPress root, open
wp-contentand create a folder namedmu-pluginsif it is missing. - Create
wp-content/mu-plugins/markdown-negotiation.phpwith the code below.
<?php
/**
* Plugin Name: Markdown negotiation
* Description: Answer Accept: text/markdown on single posts and pages.
*/
function mdn_wants_markdown() {
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
foreach ( explode( ',', $accept ) as $part ) {
$bits = array_map( 'trim', explode( ';', $part ) );
if ( strtolower( $bits[0] ) !== 'text/markdown' ) {
continue;
}
foreach ( array_slice( $bits, 1 ) as $param ) {
if ( preg_match( '/^q=0(\.0*)?$/', $param ) ) {
return false;
}
}
return true;
}
return false;
}
function mdn_node( DOMNode $n ) {
if ( $n->nodeType === XML_TEXT_NODE ) {
if ( trim( $n->textContent ) === '' && in_array( $n->parentNode->nodeName, array( 'body', 'ul', 'ol', 'table', 'thead', 'tbody', 'tr' ), true ) ) {
return '';
}
return preg_replace( '/\s+/', ' ', $n->textContent );
}
if ( $n->nodeType !== XML_ELEMENT_NODE ) {
return '';
}
$inner = '';
foreach ( $n->childNodes as $c ) {
$inner .= mdn_node( $c );
}
$tag = strtolower( $n->nodeName );
switch ( $tag ) {
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
return "\n\n" . str_repeat( '#', (int) $tag[1] ) . ' ' . trim( $inner ) . "\n\n";
case 'p':
return "\n\n" . trim( $inner ) . "\n\n";
case 'br':
return " \n";
case 'strong': case 'b':
return '**' . trim( $inner ) . '**';
case 'em': case 'i':
return '*' . trim( $inner ) . '*';
case 'a':
return '[' . trim( $inner ) . '](' . $n->getAttribute( 'href' ) . ')';
case 'img':
return ' . ')';
case 'code':
return $n->parentNode->nodeName === 'pre' ? $n->textContent : '`' . $n->textContent . '`';
case 'pre':
return "\n\n```\n" . rtrim( $n->textContent ) . "\n```\n\n";
case 'li':
$mark = $n->parentNode->nodeName === 'ol' ? '1. ' : '- ';
return $mark . trim( $inner ) . "\n";
case 'ul': case 'ol':
return "\n\n" . $inner . "\n";
case 'blockquote':
return "\n\n> " . trim( str_replace( "\n", "\n> ", trim( $inner ) ) ) . "\n\n";
case 'tr':
$cells = array();
foreach ( $n->childNodes as $c ) {
if ( $c->nodeType === XML_ELEMENT_NODE ) {
$cells[] = trim( str_replace( '|', '\|', mdn_node( $c ) ) );
}
}
$row = '| ' . implode( ' | ', $cells ) . " |\n";
if ( $n->parentNode->nodeName === 'thead' ) {
$row .= '|' . str_repeat( ' --- |', count( $cells ) ) . "\n";
}
return $row;
case 'table': case 'thead': case 'tbody':
return $tag === 'table' ? "\n\n" . $inner . "\n" : $inner;
case 'script': case 'style':
return '';
}
return $inner;
}
function mdn_html_to_markdown( $html ) {
$doc = new DOMDocument();
libxml_use_internal_errors( true );
$doc->loadHTML( '<?xml encoding="utf-8"?><body>' . $html . '</body>' );
libxml_clear_errors();
return trim( preg_replace( "/\n{3,}/", "\n\n", mdn_node( $doc->getElementsByTagName( 'body' )->item( 0 ) ) ) ) . "\n";
}
add_action( 'template_redirect', function () {
if ( ! is_singular() || is_preview() ) {
return;
}
// Tell caches the response depends on Accept, for HTML and Markdown alike.
header( 'Vary: Accept', false );
$post = get_queried_object();
if ( ! mdn_wants_markdown() || post_password_required( $post ) || ! is_post_publicly_viewable( $post ) ) {
return;
}
$body = '# ' . get_the_title( $post ) . "\n\n"
. mdn_html_to_markdown( apply_filters( 'the_content', $post->post_content ) );
status_header( 200 );
header( 'Content-Type: text/markdown; charset=utf-8' );
header( 'Link: <' . get_permalink( $post ) . '>; rel="canonical"' );
echo $body;
exit;
} );
What each part does:
mdn_wants_markdown()reads theAcceptheader and returns true only whentext/markdownis listed and not givenq=0.mdn_html_to_markdown()converts the rendered post body with PHP's built-inDOMDocument, so no Composer package is needed. It handles headings, paragraphs, links, images, bold, italics, lists, code blocks, blockquotes and simple tables.- The
template_redirecthook runs after WordPress has decided which post the URL means.header( 'Vary: Accept', false )goes out on every single post and page, HTML included, because a cache needs to hear it on both representations. post_password_required()andis_post_publicly_viewable()keep protected and unpublished content out of the Markdown route. Both fall through to the normal HTML response.- The
Link: rel="canonical"header tells search engines the Markdown copy belongs to the HTML page.
Step 2: test both representations
AIScan is the default way to check this. Run npx aiscan-cli yoursite.com, or paste the URL at aiscan.site, and read the C1 Markdown content negotiation row. The scan is free and needs no account. If you would rather check by hand, send the two requests yourself:
URL=https://yoursite.com/shipping-policy/
curl -sI -H "Accept: text/markdown" "$URL" | grep -i -E "^HTTP|content-type|vary"
curl -sI -H "Accept: text/html" "$URL" | grep -i -E "^HTTP|content-type|vary"
Expected output, from the test site:
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/markdown; charset=utf-8
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Vary: Accept
I ran four Accept values against a test page on that install:
Accept sent | Content-Type returned |
|---|---|
text/markdown | text/markdown; charset=utf-8 |
text/markdown, text/html;q=0.8 | text/markdown; charset=utf-8 |
text/markdown;q=0, text/html | text/html; charset=UTF-8 |
text/html | text/html; charset=UTF-8 |
A password-protected page returned HTML even when Markdown was requested, and a draft returned 404. The Markdown for a page with a heading, a link, a list, a code block and a table came back as clean headings, [text](url) links, - bullets, a fenced block and a pipe table.
Step 3: stop caches from mixing the two
This is where most WordPress setups fail. A page cache that keys on the URL alone stores whichever response arrives first and serves it to everyone. The result is Markdown for browsers or HTML for agents, and a passing curl followed by a failing scan.
| Layer | What to do |
|---|---|
| Page-cache plugin | Check whether it honours Vary: Accept. If it does not, exclude requests whose Accept contains text/markdown from the cache, using the plugin's own exclusion setting. |
| Host-level cache | Ask the host to bypass cache when Accept contains text/markdown, or to vary on Accept. |
| Cloudflare | Open Caching > Cache Rules and use the rule's Vary setting for the accept header. |
For Cloudflare, according to the Vary documentation fetched from Cloudflare on 1 October 2026, the origin must send a Vary header, which the snippet does, and that each listed header gets the action normalize, passthrough or bypass. passthrough keys the cache on the raw header value. normalize can drop entries it does not recognise, so confirm with the curl pair above after you change it.
After any cache change, purge the page once and run the two curl commands twice each. The first pair warms the cache and the second pair proves it.
What AIScan cannot see
The scan makes one request from one location. It cannot tell you that a cache in another region serves stale HTML, and it cannot judge whether your Markdown reads well. It also grades only the URL you give it, so scan a post and a page, not only the homepage. Open one converted page yourself with curl -s -H "Accept: text/markdown" URL | head -40 and read it the way an agent would.
What this does not cover
The snippet answers single posts and pages. Blog index pages, category archives and search results still return HTML, and a static front page counts as a page, so it works. It converts the_content only: text a theme prints outside the post body, such as a template-part heading, is not included. Page builders that print layout outside the_content can produce a nearly empty Markdown file, so open one converted page and read it before you trust the scan.
The no-code route
ThinkRank is the plugin I would reach for first on WordPress, for one reason: it handles robots.txt, robots meta, schema, sitemaps and llms.txt from a single plugin, instead of three plugins competing for the same file, and it imports settings from Rank Math, Yoast, All in One SEO and SEOPress. According to its own pricing table, verified on 1 October 2026, ThinkRank lists "Markdown for AI & llms-full.txt" as a Pro feature that serves agents a clean Markdown copy of any post on the same URL. I did not run it for this guide, so test it with the same curl pair. Yoast and Rank Math are mature plugins that cover titles, meta and schema well, and I did not test either for Markdown output, so this guide makes no claim about them. The snippet above works alongside both.
Next step
Run npx aiscan-cli yoursite.com and read C1. If it still fails, the cause is almost always cache, so repeat Step 3. The same check on other stacks is in serving text/markdown on Next.js, the population-level numbers are in documentation sites and AI agent readiness, and the wider context for answerable pages is in make a page answerable by AI. The check page is at /docs/checks/content, the WordPress page at /docs/platforms/wordpress, and every other fix is in /guides.
Frequently asked questions
How do I make WordPress return Markdown when an agent sends Accept: text/markdown?
Add a must-use plugin at wp-content/mu-plugins/markdown-negotiation.php that hooks template_redirect, checks the Accept header, and prints the converted post body with Content-Type text/markdown. On WordPress 7.1.2 with PHP 8.3.6 the same URL returned text/markdown to one request and text/html to the other. Send Vary: Accept on both.
I added the file but curl still returns text/html. Why?
Check three things in order. The file must sit directly in wp-content/mu-plugins, not in a subfolder. The URL must be a single post or page, because archives are not covered. And a cache may be answering before WordPress runs. Purge the page, then request it twice with the Accept header.
My browser shows Markdown, or agents get HTML after a cache warm-up. What happened?
A cache keyed on the URL alone stored the first response and served it to everyone. Make the cache honour Vary: Accept, or exclude requests whose Accept contains text/markdown. On Cloudflare, use the Vary setting in Cache Rules for the accept header and re-test with two curl requests each.
The site shows a white screen or critical error after I added the file. How do I recover?
Delete or rename markdown-negotiation.php over SFTP and the site loads again, because must-use plugins have no activation step. The usual cause is a copy error such as a missing semicolon or a stray character before the opening php tag. Run php -l on the file before uploading it again.
Will a Markdown copy at the same URL cause duplicate content in search?
The snippet sends a Link header with rel canonical pointing at the HTML permalink, and Vary: Accept on both versions. Search engines send Accept: text/html, so they keep receiving the page they already index. Watch Search Console after the change like you would for any template edit.
Does this cover my blog index, categories and search results?
No. It answers single posts and pages only, and a static front page counts as a page. Archives, category pages and search results still return HTML. If your homepage is a latest-posts list, C1 on the homepage will keep failing, so scan a post instead.
Are password-protected or draft posts exposed as Markdown?
No. The snippet skips posts that need a password and posts that are not publicly viewable. In the test, a password-protected page returned HTML to a Markdown request and a draft returned 404.
The Markdown is almost empty. What is wrong?
The snippet converts the output of the_content for the post. A page builder or theme that prints layout outside the_content leaves nothing to convert. Open one converted page with curl and read it. If the text is missing, that content lives outside the post body and needs its own handling, or ThinkRank Pro's Markdown feature may fit better.
Related guides
What a Failing AIScan Check Really Says: 2,961 Fail Lines Audited
A failing check on a scan report is supposed to be an instruction. The site owner reads the line, knows what the scanner asked for, knows what came back, and knows what to change. We audited whether…
Rate-limit AI crawlers without blocking them
Most advice on rate limiting AI crawlers stops at "add a limit". The part that costs you is where the limit applies and what it answers. A limit that covers /robots.txt and /llms.txt makes your own…
Which Agent-Readiness Checks Travel Together: 739 Sites, Three Bundles
Fix one agentreadiness check and a second one often moves with it. Fix another and nothing else changes. A scan report lists every check as its own line, so the reader cannot see which lines are…
Platform League Table: WordPress, Next.js, Astro and Shopify Across 728 Sites
Every few weeks someone asks which platform produces the most agentready sites. WordPress, Next.js, Astro and Shopify all have fans, and a ranked table is the answer people expect. We have the data…
