---
title: "How to serve text/markdown to AI agents on WordPress"
slug: markdown-for-ai-agents-wordpress
published: 2026-10-01T13:17:54.520693+00:00
updated: 2026-10-04T12:03:06.709458+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: check:C1, platform:wordpress, WordPress, Markdown, content negotiation, mu-plugins, AI agents
description: "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."
url: https://aiscan.site/blog/markdown-for-ai-agents-wordpress
---

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](https://www.rfc-editor.org/rfc/rfc9110.html#name-proactive-negotiation): 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](https://developer.wordpress.org/advanced-administration/plugins/mu-plugins/), 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
<?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](https://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:

```bash
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](https://developers.cloudflare.com/cache/concepts/vary/) 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](https://thinkrank.ai) 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](https://aiscan.site/blog/markdown-for-ai-agents-nextjs), the population-level numbers are in [documentation sites and AI agent readiness](https://aiscan.site/blog/documentation-sites-ai-agent-readiness), and the wider context for answerable pages is in [make a page answerable by AI](https://aiscan.site/blog/make-a-page-answerable-by-ai). The check page is at [/docs/checks/content](https://aiscan.site/docs/checks/content), the WordPress page at [/docs/platforms/wordpress](https://aiscan.site/docs/platforms/wordpress), and every other fix is in [/guides](https://aiscan.site/guides).

