Serve markdown to AI agents on WordPress, with the WordPress logo — AIScan cover
Serve markdown to AI agents on WordPress, with the WordPress logo — AIScan cover
AI Readiness

How to serve text/markdown to AI agents on WordPress

Return Markdown to AI agents that send Accept: text/markdown on WordPress with one mu-plugin file, Vary: Accept and a cache rule, tested on WordPress 7.1.2.

AAIScan Content Team 1 Oct 2026 9 min read
#WordPress#Markdown#content negotiation#mu-plugins#AI agents

This guide covers C1 · Content — for WordPress.

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 thisTime
Serve Markdown on the same URLOne must-use plugin file that answers Accept: text/markdown on posts and pages10 min
Keep caches honestVary: Accept on every post and page response, plus a cache rule if a CDN sits in front10 min
Skip the codeThinkRank Pro lists Markdown for AI on the same URL as a feature5 min
Confirm it worksnpx aiscan-cli yoursite.com, then read the C1 row1 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.

  1. Connect over SFTP or your host's file manager.
  2. In the WordPress root, open wp-content and create a folder named mu-plugins if it is missing.
  3. Create wp-content/mu-plugins/markdown-negotiation.php with 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 '![' . $n->getAttribute( 'alt' ) . '](' . $n->getAttribute( 'src' ) . ')';
		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 the Accept header and returns true only when text/markdown is listed and not given q=0.
  • mdn_html_to_markdown() converts the rendered post body with PHP's built-in DOMDocument, so no Composer package is needed. It handles headings, paragraphs, links, images, bold, italics, lists, code blocks, blockquotes and simple tables.
  • The template_redirect hook 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() and is_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 sentContent-Type returned
text/markdowntext/markdown; charset=utf-8
text/markdown, text/html;q=0.8text/markdown; charset=utf-8
text/markdown;q=0, text/htmltext/html; charset=UTF-8
text/htmltext/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.

LayerWhat to do
Page-cache pluginCheck 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 cacheAsk the host to bypass cache when Accept contains text/markdown, or to vary on Accept.
CloudflareOpen 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