<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Content-Negotiation on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/content-negotiation/</link><description>Recent content in Content-Negotiation on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.163.3</generator><language>en-us</language><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/content-negotiation/feed.xml" rel="self" type="application/rss+xml"/><item><title>One URL, two readers: serving HTML to people and Markdown to agents</title><link>https://jonesrussell.github.io/blog/agent-readable-content-negotiation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/agent-readable-content-negotiation/</guid><category>php</category><category>waaseyaa</category><blog:tag>waaseyaa</blog:tag><blog:tag>content-negotiation</blog:tag><blog:tag>ai-agents</blog:tag><blog:tag>php</blog:tag><description>How Waaseyaa serves the same content as a web page for people and clean Markdown for AI agents from a single URL, using HTTP content negotiation.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p>The web has two kinds of readers now: people and agents. Most stacks make you build a second system to serve the second one, a separate API with its own routes, auth, and serializers. This post shows the approach <a href="https://github.com/waaseyaa/framework">Waaseyaa</a> takes instead: one URL serves a human a web page and an AI agent clean Markdown, decided by HTTP content negotiation. It covers the content type you define, the negotiation that picks the format, and the agent-facing routes that come along for free.</p>
<blockquote>
<p><strong>Prerequisites:</strong> Familiarity with HTTP <code>Accept</code> headers and basic PHP. Waaseyaa is an early-alpha PHP framework, so treat the specifics as a moving target.</p>
</blockquote>
<h2 id="define-the-content-once">Define the content once</h2>
<p>You describe the shape of your content one time. In Waaseyaa that is a single command:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>waaseyaa make:content-type story --fields<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;title:string,body:text,source_url:string&#34;</span>
</span></span></code></pre></div><p>That scaffolds a <code>story</code> content type with three fields. Then you add an entry:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>waaseyaa entity:create story --field title<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;The Five Totems&#34;</span> --field status<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>You never write a controller, a route, or a serializer for any of this. The type is the only thing you author. Everything that follows is the framework reading that one definition.</p>
<h2 id="one-url-negotiated-by-accept">One URL, negotiated by Accept</h2>
<p>The same canonical path, <code>/{type}/{id}</code>, serves both audiences. What comes back depends on the request&rsquo;s <code>Accept</code> header. A browser sends <code>text/html</code> and gets a rendered page. An agent that asks for <code>text/markdown</code> gets Markdown. The decision lives in <code>MediaTypeAcceptNegotiator</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">namespace</span> <span style="color:#a6e22e">Waaseyaa\Foundation\Http\ContentNegotiation</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">MediaTypeAcceptNegotiator</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> <span style="color:#a6e22e">HTML</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;text/html&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> <span style="color:#a6e22e">MARKDOWN</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;text/markdown&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">negotiate</span>(<span style="color:#a6e22e">string</span> $acceptHeader, <span style="color:#66d9ef">array</span> $supported, <span style="color:#a6e22e">string</span> $default)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Ranks the Accept entries (RFC 7231) and returns the best supported match.
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The negotiator parses the <code>Accept</code> header by quality value and returns the most specific supported media type. The human path and the agent path converge on one URL, so there is no <code>/api/story/123</code> shadow of <code>/story/123</code> to keep in sync.</p>
<h2 id="a-human-toggle-for-the-same-switch">A human toggle for the same switch</h2>
<p><code>Accept</code> headers are invisible in a browser, so there is also an explicit query override. The negotiator recognizes it directly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">resolveQueryOverride</span>(<span style="color:#66d9ef">array</span> $query, <span style="color:#66d9ef">array</span> $supported)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">\array_key_exists</span>(<span style="color:#e6db74">&#39;raw&#39;</span>, $query)) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">MARKDOWN</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($query[<span style="color:#e6db74">&#39;format&#39;</span>]) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">\is_string</span>($query[<span style="color:#e6db74">&#39;format&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">match</span> (<span style="color:#a6e22e">strtolower</span>(<span style="color:#a6e22e">trim</span>($query[<span style="color:#e6db74">&#39;format&#39;</span>]))) {
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#39;md&#39;</span>, <span style="color:#e6db74">&#39;markdown&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">MARKDOWN</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#39;html&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">HTML</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">default</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Append <code>?raw</code> or <code>?format=md</code> to any content URL and you see exactly what an agent sees. That makes the agent-facing output something you can eyeball in a browser, not a black box you have to script against to inspect.</p>
<h2 id="caching-two-formats-at-one-address">Caching two formats at one address</h2>
<p>Serving two representations from one URL has a well-known hazard: a shared cache can hand the HTML variant to an agent or the Markdown to a browser. <code>SsrPageHandler</code> guards against that by varying the cache on the negotiated type:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$mediaType <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">negotiateMediaType</span>($httpRequest);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ...render either Markdown or HTML based on $mediaType...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$headers[<span style="color:#e6db74">&#39;Vary&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Accept&#39;</span>;
</span></span></code></pre></div><p>The <code>Vary: Accept</code> header tells every cache in the chain that the response depends on the request&rsquo;s <code>Accept</code> header, so the Markdown and HTML variants never cross-contaminate. One URL, two cache entries, no leakage.</p>
<h2 id="the-agent-facing-routes-you-get-for-free">The agent-facing routes you get for free</h2>
<p>Because the framework already knows which content types are public, it can publish the discovery surface agents and crawlers expect without you wiring anything. <code>SeoPublicController</code> exposes three zero-config routes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">robotsTxt</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>   <span style="color:#75715e">// /robots.txt
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">sitemapXml</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>   <span style="color:#75715e">// /sitemap.xml
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">llmsTxt</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>      <span style="color:#75715e">// /llms.txt
</span></span></span></code></pre></div><p><code>/llms.txt</code> is the emerging convention for telling language models what a site contains and where to look. Here it is generated from the same content-type metadata that drives everything else, alongside schema.org JSON-LD injected into the page head. Your content becomes legible to an AI assistant the moment it is published, without a second pipeline.</p>
<h2 id="why-this-matters">Why this matters</h2>
<p>As more of the web gets read through AI assistants, the content you publish is increasingly consumed by something that does not render HTML. The common answer is to stand up a parallel API: more routes, more auth surface, more drift between what people see and what machines see. Negotiating on one URL collapses that back into a single source of truth. You define the content once, and the same address answers both readers correctly.</p>
<p>It is still alpha, and the write side has rougher edges than the read side. But the read path holds the thesis: one URL, two readers, no second system.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>