WordPress markdown content negotiation means one URL, two representations. A browser that asks for text/html gets the normal page. An AI agent that sends Accept: text/markdown gets the same post as clean markdown, with a Content-Type: text/markdown; charset=utf-8 response and a Vary: Accept header so caches keep the two apart. You can build it with under 200 lines of PHP, a Composer package, and one cache rule. This post walks through the code, the cache configuration, and the curl tests that prove it works.
What Content Negotiation Actually Does
HTTP has always let a client say which formats it prefers. RFC 9110 defines the Accept request header and its quality values, or q-values. A q-value is a weight from 0 to 1. A missing q-value means 1, and q=0 means “not acceptable.”
The server reads that header, picks a representation, and says what it chose. RFC 9110 also says an origin that negotiates should send Vary, naming the request headers that shaped the response. For this feature that header is Vary: Accept.
The media type itself is registered. RFC 7763 defines text/markdown and makes the charset parameter required, with no default. So a correct response header reads text/markdown; charset=utf-8, not a bare text/markdown.
Why agents ask for markdown
Agents pay for every token they read. Cloudflare’s February 12, 2026 announcement measured its own post at 16,180 tokens as HTML and 3,150 tokens as markdown, about an 80% cut. The same post says coding agents such as Claude Code and OpenCode already send Accept: text/markdown with their requests.
That is the whole case. Markdown gives an agent your content without your navigation, scripts, and class names. We have not seen published evidence that serving it changes search rankings or earns AI citations, so treat it as a format courtesy, not a ranking lever.
Three Ways to Serve Markdown From WordPress
Before writing code, decide where the conversion should happen. Each option below works. They differ in control, cost, and how much they depend on your host.
Let the CDN convert it
Cloudflare’s Markdown for Agents converts HTML to markdown at the edge when a request sends Accept: text/markdown. Its response adds Vary: Accept, an x-markdown-tokens estimate, and YAML front matter built from your meta tags. Cloudflare lists it for Pro, Business, and Enterprise plans and for SSL for SaaS customers.
The tradeoffs come from converting the whole rendered page. Cloudflare strips non-content elements, but you do not choose what counts as content. It also drops ETag and Last-Modified on converted responses and only converts origin responses up to 2 MB.
Install a plugin
Several plugins already do this. Roots publishes roots/post-content-to-markdown, which handles the Accept header, a ?format=markdown parameter, and .md URLs. Roots says it parses q-values, sends Vary: Accept, and renders blocks before converting. Joost de Valk’s Markdown Alternate, on GitHub, serves .md URLs, honors the Accept header, and adds a <link rel="alternate"> tag.
Check any plugin before you trust it. In April 2026 Roots showed that at least one SEO plugin offered .md URLs but ignored the Accept header entirely. An agent sending the header got HTML.
Write it yourself
Custom code makes sense when you want exact control over which post types qualify, what the front matter holds, and how the response interacts with your cache. The rest of this post builds that version. It is small enough to read in one sitting. We also publish plugin work on WordPress.org, listed on our WordPress page.
Build the Plugin
The plugin does four jobs. It decides whether a request qualifies, sends the right headers, renders the post body to markdown, and advertises the markdown version in the HTML head.
File layout
Create a normal plugin folder. Composer pulls in league/html-to-markdown, the converter that does the HTML to markdown work.
wp-content/plugins/fkw-markdown-negotiation/
├── composer.json
├── fkw-markdown-negotiation.php
├── markdown-template.php
└── vendor/ (created by composer install)
The composer.json needs one dependency. The library’s README lists PHP 7.2 or newer, and version 5.1.1 is the current release on GitHub.
{
"name": "fkw/markdown-negotiation",
"type": "wordpress-plugin",
"require": {
"php": ">=7.4",
"league/html-to-markdown": "^5.1"
}
}
The main plugin file
Here is the complete file. The sections after it explain the decisions that are easy to get wrong.
<?php
/**
* Plugin Name: FKW Markdown Negotiation
* Description: Serves published posts and pages as text/markdown when a client asks for it with the Accept header.
* Version: 1.0.0
* Requires PHP: 7.4
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
if ( is_readable( __DIR__ . '/vendor/autoload.php' ) ) {
require_once __DIR__ . '/vendor/autoload.php';
}
/**
* Returns the weight (q-value) an Accept header gives a media type.
* The most specific matching range wins: exact type, then type/*, then * / *.
*/
function fkw_md_accept_weight( string $accept, string $type ): float {
$type = strtolower( $type );
$main = strtok( $type, '/' );
$weight = 0.0;
$specificity = -1;
foreach ( explode( ',', $accept ) as $range ) {
$params = array_map( 'trim', explode( ';', $range ) );
$media = strtolower( (string) array_shift( $params ) );
$q = 1.0;
foreach ( $params as $param ) {
if ( 0 === stripos( $param, 'q=' ) ) {
$q = (float) substr( $param, 2 );
}
}
if ( $media === $type ) {
$match = 2;
} elseif ( $media === $main . '/*' ) {
$match = 1;
} elseif ( '*/*' === $media ) {
$match = 0;
} else {
continue;
}
if ( $match > $specificity ) {
$specificity = $match;
$weight = $q;
}
}
return max( 0.0, min( 1.0, $weight ) );
}
/**
* True only when the client names text/markdown explicitly and ranks it
* at least as high as text/html. Wildcards alone never trigger markdown.
*/
function fkw_md_client_wants_markdown(): bool {
$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? (string) $_SERVER['HTTP_ACCEPT'] : '';
if ( false === stripos( $accept, 'text/markdown' ) ) {
return false;
}
$markdown = fkw_md_accept_weight( $accept, 'text/markdown' );
return $markdown > 0 && $markdown >= fkw_md_accept_weight( $accept, 'text/html' );
}
/**
* Returns the post to serve, or null when this request should stay HTML.
*/
function fkw_md_eligible_post(): ?WP_Post {
if ( ! is_singular() || is_preview() || is_embed() ) {
return null;
}
$post = get_queried_object();
if ( ! $post instanceof WP_Post ) {
return null;
}
$types = (array) apply_filters( 'fkw_md_post_types', array( 'post', 'page' ) );
if ( ! in_array( $post->post_type, $types, true ) ) {
return null;
}
// Drafts, private posts and password protected posts stay HTML only.
if ( false === wp_get_canonical_url( $post ) || '' !== $post->post_password ) {
return null;
}
return $post;
}
/**
* Builds the markdown document: YAML front matter, then the converted body.
*/
function fkw_md_build_document( WP_Post $post ): string {
setup_postdata( $post );
$html = apply_filters( 'the_content', $post->post_content );
$converter = new League\HTMLToMarkdown\HtmlConverter(
array(
'header_style' => 'atx',
'hard_break' => true,
'remove_nodes' => 'script style noscript iframe form button svg',
'strip_tags' => true,
)
);
$converter->getEnvironment()->addConverter( new League\HTMLToMarkdown\Converter\TableConverter() );
$body = trim( $converter->convert( $html ) );
$meta = array(
'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ),
'url' => wp_get_canonical_url( $post ),
'date' => get_post_time( 'c', true, $post ),
'modified' => get_post_modified_time( 'c', true, $post ),
'author' => get_the_author_meta( 'display_name', (int) $post->post_author ),
'description' => has_excerpt( $post ) ? wp_strip_all_tags( get_the_excerpt( $post ) ) : '',
);
$meta = (array) apply_filters( 'fkw_md_front_matter', $meta, $post );
$front = "---\n";
foreach ( $meta as $key => $value ) {
if ( '' === $value || null === $value ) {
continue;
}
// A JSON string is a valid YAML double quoted scalar.
$front .= $key . ': ' . wp_json_encode( (string) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . "\n";
}
$front .= "---\n\n";
wp_reset_postdata();
return $front . '# ' . $meta['title'] . "\n\n" . $body . "\n";
}
/**
* Decides the representation and sends headers. Runs after redirect_canonical
* (priority 10) so markdown is only ever served on the canonical URL.
* Headers go out here, not in the template, because WordPress exits on
* HEAD requests right after template_redirect.
*/
function fkw_md_template_redirect(): void {
$post = fkw_md_eligible_post();
if ( null === $post ) {
return;
}
// Both representations must carry Vary, or a shared cache can mix them up.
header( 'Vary: Accept', false );
if ( ! fkw_md_client_wants_markdown() || ! class_exists( 'League\HTMLToMarkdown\HtmlConverter' ) ) {
return;
}
$GLOBALS['fkw_md_document'] = fkw_md_build_document( $post );
header( 'Content-Type: text/markdown; charset=utf-8' );
header( 'Link: <' . esc_url_raw( wp_get_canonical_url( $post ) ) . '>; rel="canonical"', false );
header( 'X-Markdown-Tokens: ' . (int) ceil( strlen( $GLOBALS['fkw_md_document'] ) / 4 ) );
}
add_action( 'template_redirect', 'fkw_md_template_redirect', 20 );
/**
* Swaps in the markdown template only when a document was built.
*/
function fkw_md_template_include( string $template ): string {
return isset( $GLOBALS['fkw_md_document'] ) ? __DIR__ . '/markdown-template.php' : $template;
}
add_filter( 'template_include', 'fkw_md_template_include', PHP_INT_MAX );
/**
* Advertises the markdown representation in the HTML head.
*/
function fkw_md_head_link(): void {
$post = fkw_md_eligible_post();
if ( null !== $post ) {
printf(
'<link rel="alternate" type="text/markdown" href="%s" />' . "\n",
esc_url( wp_get_canonical_url( $post ) )
);
}
}
add_action( 'wp_head', 'fkw_md_head_link' );
The template file is three lines. WordPress includes it in place of the theme template when a markdown document exists.
<?php
/**
* Template used by FKW Markdown Negotiation. Prints the prepared markdown.
*/
defined( 'ABSPATH' ) || exit;
echo $GLOBALS['fkw_md_document']; // phpcs:ignore WordPress.Security.EscapeOutput -- markdown, not HTML.
Install the dependency and activate the plugin. The last line uses WP-CLI, but the Plugins screen works too.
cd wp-content/plugins/fkw-markdown-negotiation
composer install --no-dev --optimize-autoloader
wp plugin activate fkw-markdown-negotiation
The Decisions That Matter
Most broken implementations fail in one of five places. The plugin above handles each one on purpose.
Read q-values, not substrings
A browser sends something like text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8. That */* technically accepts markdown, but nobody wants a raw markdown page in a browser tab. So fkw_md_client_wants_markdown() requires text/markdown by name. Then it compares weights and serves markdown only when markdown ranks at least as high as HTML.
A plain substring check gets the edge cases wrong. It would send markdown to text/markdown;q=0, which explicitly refuses it. It would also send markdown to text/html, text/markdown;q=0.5, which prefers HTML.
Use template_redirect for headers and template_include for the body
The WordPress docs describe template_redirect as the hook to use “with full knowledge of the content that has been queried.” The same docs warn against including a template there and calling exit(), because later template_redirect callbacks never run. They point to the template_include filter instead.
The plugin splits the work for a second reason. In template-loader.php, WordPress exits on HEAD requests right after template_redirect and before template_include. Headers sent in the template would never reach a HEAD request. Sending them in template_redirect means curl -I reports the true content type.
Priority 20 is deliberate too. Core hooks redirect_canonical to template_redirect at the default priority of 10. Running after it means a request for a non-canonical URL redirects first, and markdown only ever comes from the canonical URL.
One hook that looks right is the wrong place. The wp_headers filter runs inside WP::send_headers(), which fires before WordPress runs the main query. At that point is_singular() cannot answer yet.
Send Vary on both representations
This is the most common mistake. Adding Vary: Accept only to the markdown response is not enough. If the HTML response omits it, a shared cache can store the HTML version and hand it to the next agent, or store the markdown and hand it to the next browser. The plugin sends Vary on every eligible post, whichever format wins.
Keep private content private
wp_get_canonical_url() returns false for posts that do not exist or are not published. The plugin uses that to skip drafts and private posts. It also skips any post with a password, even when the visitor has the password cookie. A cached markdown copy of a protected post would leak it to everyone.
Render blocks before converting
Raw post_content holds block comments and unexpanded shortcodes. Core attaches do_blocks to the_content at priority 9 and do_shortcode at priority 11. Running the content through apply_filters( 'the_content', ... ) gives the converter the same HTML a visitor sees inside the post body.
The remove_nodes option drops scripts, styles, forms, and embeds before conversion. strip_tags keeps the text of tags that have no markdown equivalent. The plugin adds TableConverter, because the library leaves tables alone unless you register it.
What the Response Looks Like
A request with Accept: text/markdown returns these headers on a published post.
- Content-Type:
text/markdown; charset=utf-8, as RFC 7763 requires. - Link: the post’s canonical HTML URL with
rel="canonical". Google documents this header form and asks for absolute URLs. - Vary:
Accept, on this response and on the HTML one. - X-Markdown-Tokens: a rough size estimate at four characters per token. Cloudflare and Markdown Alternate send similar headers. Treat it as a hint, not a count.
The body opens with YAML front matter (title, canonical URL, published and modified dates in ISO 8601, author, and the excerpt when one exists). The title follows as a level one heading, then the converted content. Each value goes through wp_json_encode(), because a JSON string is also a valid YAML double quoted string. That keeps quotes and colons in titles from breaking the front matter. Two filters, fkw_md_post_types and fkw_md_front_matter, let a theme change the post types and fields without editing the plugin.
Configure Your Cache
The plugin is the easy half. The cache in front of WordPress decides whether agents ever reach it.
Page cache plugins
The WordPress reference for wp_headers notes that caching systems may serve a page before WordPress runs at all. The same applies here. A page cache that writes HTML files to disk and serves them before PHP loads will answer an agent with HTML, and this plugin never runs. Check whether your cache plugin can skip requests by header. If it cannot, exclude markdown requests at the server or CDN instead.
Nginx FastCGI cache
Since version 1.7.7, Nginx caches a response with a Vary header separately for each value of the listed request headers. That keeps the cache correct but fragments it, because browsers send many slightly different Accept strings.
A cleaner setup sends every markdown request straight to PHP and lets only HTML into the cache. A map flags requests that mention text/markdown. Then fastcgi_cache_bypass skips the cache lookup, and fastcgi_no_cache stops the response from being saved.
# http {} context: flag any request that mentions text/markdown.
map $http_accept $fkw_markdown_request {
default 0;
"~*text/markdown" 1;
}
# server {} or location ~ \.php$ {} context, next to your existing fastcgi_cache lines.
fastcgi_cache_bypass $fkw_markdown_request;
fastcgi_no_cache $fkw_markdown_request;
# Only HTML reaches the cache now, so one entry per URL is enough.
# Merge Vary into any fastcgi_ignore_headers line you already have.
fastcgi_ignore_headers Vary;
The fastcgi_ignore_headers Vary line only changes how Nginx stores responses. Nginx still passes the Vary header on to browsers and CDNs. Run nginx -t before reloading. If you already have a fastcgi_ignore_headers line, add Vary to it rather than adding a second line.
Cloudflare
Cloudflare’s CDN “does not cache HTML or JSON by default.” If you never turned on HTML caching, Cloudflare passes these requests to your origin and the plugin answers them. You only need a rule when a Cache Rule makes your HTML eligible for cache.
Cloudflare’s docs also say that by default it “does not consider vary values in caching decisions.” That changed on July 2, 2026, when Cloudflare added Vary support to Cache Rules on every plan, Free included. In a Cache Rule that caches your posts, set the Vary option for accept to normalize, and list the two media types you serve. The API form of that setting looks like this.
"action_parameters": {
"cache": true,
"vary": {
"default": {
"action": "bypass"
},
"headers": {
"accept": {
"action": "normalize",
"media_types": ["text/html", "text/markdown"]
}
}
}
}
Normalizing lowercases the header, sorts types by q-value, and drops any type not in your list. A browser and an agent then land in different cache entries, while every browser shares one. Cloudflare only splits the cache when the origin response carries Vary, which is why the HTML response needs it too.
If you prefer the simpler route, add a Cache Rule set to Bypass cache for markdown requests, placed so it wins over your caching rule. This expression matches them.
any(http.request.headers["accept"][*] contains "text/markdown")
If you redirect HTTP to HTTPS through Cloudflare, confirm your SSL mode first. Our post on fixing an endless Cloudflare redirect loop covers that setting.
Test It With curl
Five commands cover the behavior. Run them against a published post, then repeat the first and third to confirm the cache returns the right version on a hit.
URL="https://example.com/sample-post/"
# 1. An agent asking for markdown gets markdown, with Vary and a canonical Link.
curl -sI -H "Accept: text/markdown" "$URL" | grep -iE '^(content-type|vary|link|x-markdown-tokens)'
# 2. The body starts with front matter, then the title.
curl -s -H "Accept: text/markdown" "$URL" | head -n 12
# 3. A browser style Accept header still gets HTML, and also carries Vary.
curl -sI -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" "$URL" | grep -iE '^(content-type|vary)'
# 4. Markdown ranked below HTML stays HTML.
curl -s -o /dev/null -w '%{content_type}\n' -H "Accept: text/html, text/markdown;q=0.5" "$URL"
# 5. Repeat 1 and 3 and compare the cache status header (cf-cache-status on Cloudflare).
curl -sI -H "Accept: text/markdown" "$URL" | grep -i 'cache-status'
Here is what each result should show.
- Test 1:
text/markdown; charset=utf-8,Vary: Accept, a canonicalLink, and a token estimate. - Test 2: front matter between two
---lines, then the title as a heading. - Test 3:
text/htmlplusVary: Accept. - Test 4:
text/html, because the client ranked markdown lower. - Test 5: on Cloudflare, read
cf-cache-statusnext to the content type. A markdown request must never come back as a cached HTML copy.
We ran tests 1 through 4 against the plugin on PHP 8.3 with a stand in for WordPress and the converter. They also cover drafts, password protected posts, archives, text/markdown;q=0, and HEAD requests, which kept the markdown headers. Run them on staging against real WordPress before you ship.
Edge Cases Worth Knowing
- Archives and the blog index: the plugin only converts single posts and pages. Archives stay HTML and send no
Vary, since they have one representation. - Custom post types: add them through the
fkw_md_post_typesfilter. Products and other types often keep key data in fields outsidepost_content, so add those to the front matter too. - Page builders: some builders store layout outside the post content or render it with scripts. Test a few real pages and read the markdown before you trust it.
- Paginated posts: the plugin converts the full
post_content, so a post split with the page break block comes back as one document. - Theme content: headers, footers, and sidebars never appear. That is the point, but it also means calls to action in a template part will not reach agents.
Which Approach Fits Your Site
- Cloudflare Pro or higher, standard content: try Markdown for Agents first, and read its output on a few pages.
- Composer based WordPress install:
roots/post-content-to-markdownis a short install and handles the headers. - Custom post types, custom front matter, or a strict cache setup: build the plugin above and own every header.
- A Laravel app beside your WordPress site: Laravel Cloud announced its own Markdown for Agents feature on February 17, 2026, keyed on the same header.
Whichever route you pick, run the curl tests through your real cache. A setup that works at the origin and fails at the edge is the usual failure.
Need markdown negotiation built, or checked, on your WordPress or Laravel site? Contact FKW Digital or book a consultation.




Leave a Reply