Servire text/markdown agli agenti AI senza Cloudflare: content negotiation RFC 9110 on-origin con Laravel, Symfony e PHP vanilla
Il 16 febbraio 2026, quattro giorni dopo il lancio di "Markdown for Agents" da parte di Cloudflare, ho passato una domenica mattina a misurare il traffico del mio blog sandbox ospitato su un Hetzner CCX33 (8 vCPU AMD EPYC 9454P, 32 GB RAM DDR5, 240 GB NVMe) con stack Laravel 12, PHP-FPM 8.3, Nginx 1.26 e Redis 7.2. L'obiettivo era capire chi davvero mandasse Accept: text/markdown nell'header HTTP e quanto mi costasse in token, se quegli agenti fossero miei (scraper che popolano una mia pipeline di knowledge management). Sullo stesso articolo tecnico da 2.400 parole ho misurato 16.180 token in HTML (count cl100k_base via tiktoken Python) e 3.150 token in Markdown pulito dopo preprocessing. L'ottanta percento di riduzione non è un'iperbole di marketing di Cloudflare: è la matematica di un <h2 class="section-title" id="about">About Us</h2> che pesa 12-15 token quando l'equivalente ## About Us ne pesa 3.
Il blog post Cloudflare del 12 febbraio 2026 documenta esattamente lo stesso rapporto usando il proprio articolo come benchmark. La notizia vera, però, non è che Cloudflare converta l'HTML in Markdown all'edge per te. La notizia è che il pattern sottostante è content negotiation standard RFC 9110, un meccanismo documentato e pubblico dal 1996, e che puoi implementarlo direttamente sul tuo backend Laravel, Symfony o PHP vanilla senza dipendere da un edge provider. Se sei su Aruba, OVH o un server non instradato da Cloudflare, se vuoi mantenere il controllo del Content-Signal e della logica di cache, o se semplicemente rifiuti il lock-in di "un'altra feature gestita dal proxy", questo articolo ti mostra come farlo. Nessuna magia, nessun toggle nel dashboard di qualcun altro. Solo Accept: text/markdown, Vary: Accept, e sessanta righe di middleware scritte bene.
Perché gli agenti AI stanno chiedendo Markdown al tuo web server?
La risposta breve è: perché pagano ogni token di input processato, e HTML è pieno di token inutili. La risposta lunga richiede due dati e una distinzione.
Primo dato. Il report Cloudflare di giugno 2025 ha misurato il crawl-to-referral ratio dei principali AI bot. Google rimanda una visita ogni 14 crawl. OpenAI una ogni 1.700. Anthropic una ogni 73.000. Il deal storico "mi scansioni, mi mandi traffico" è quantitativamente rotto nel caso AI: gli agenti usano il contenuto per rispondere dentro la propria interfaccia, non per redirigerlo a te. Secondo dato. Da novembre 2025 gli agenti di coding production-grade (Claude Code, Cursor, OpenCode) inviano di default header HTTP che includono text/markdown nella lista dei media type accettati, ordinato prima di text/html con quality factor 1.0. Non lo fanno perché Cloudflare glielo ha chiesto. Lo fanno perché la loro pipeline di context ingestion risparmia fino all'ottanta percento di token quando riceve Markdown, e quel risparmio si traduce in una bolletta Anthropic che scende di oltre metà a parità di chiamate.
La distinzione importante riguarda chi sei tu. Se sei il publisher di un blog letto da umani, il tuo business non cambia: gli agenti continuerebbero a scansionarti anche senza Markdown. Se sei una PMI che integra agenti nel proprio stack interno (quindi i miei clienti reali, e me stesso nella mia pipeline personale di automazione AI), allora ogni scansione che i tuoi agenti fanno verso i tuoi asset di documentazione è un costo API che puoi tagliare di un ordine di grandezza. Non è SEO. Non è AEO. È cost governance sull'input di contesto. Guardando il mio dashboard Langfuse delle ultime quattro settimane, i soli retrieval da documentazione interna hanno consumato 2,8 milioni di token di input. Sostituire HTML con Markdown li porta a 560 mila. A $3 per milione su Claude Sonnet 4.6, sono $6,72 al mese di risparmio su un singolo progetto giocattolo. Su un RAG aziendale con corpus più grosso, i conti si moltiplicano.
Se vuoi vedere come strutturo pipeline AI di produzione integrando governance dei costi, retrieval tokenizzato ed engineering dei confini operativi degli agenti, nel mio hub dedicato all'AI per aziende trovi articoli tecnici con metodologia applicata e perimetro dichiarato.
Cosa dice davvero RFC 9110 sulla content negotiation
Il documento di riferimento è RFC 9110 HTTP Semantics, pubblicato nel giugno 2022 come stabilizzazione di quindici anni di draft. La sezione 12 descrive due modalità di negoziazione del contenuto.
La prima è proactive (server-driven) negotiation, descritta in §12.1. Il client dichiara le proprie preferenze in header di richiesta (tipicamente Accept, Accept-Language, Accept-Encoding) e il server sceglie la rappresentazione migliore disponibile. La sintassi di Accept è definita in §12.5.1 e consente q-values per esprimere preferenze relative: Accept: text/markdown;q=1.0, text/html;q=0.9 significa "preferisco Markdown, accetto HTML se Markdown non è disponibile". La MIME type text/markdown è formalizzata da RFC 7763 del 2016.
La seconda modalità è reactive (agent-driven) negotiation, §12.2, dove il server risponde con 300 Multiple Choices fornendo una lista di varianti e il client sceglie. Praticamente inutilizzata sul web moderno. La ignoriamo.
Due header lato server sono critici. Vary: Accept nella risposta dichiara che la rappresentazione dipende dall'header Accept della richiesta: ogni cache intermedia (CDN, proxy, browser) deve considerare quell'header parte della chiave di cache, altrimenti servirebbe Markdown a un utente o HTML a un bot. Content-Type: text/markdown; charset=utf-8 identifica la rappresentazione servita. Lo status code 406 Not Acceptable si usa quando la risorsa non è disponibile in nessuno dei media type accettati dal client; nella pratica, è raro che sia necessario, perché tutti gli agenti accettano anche text/html come fallback.
La ragione per cui questo non è cloaking è topologica. Cloaking significa servire URL diversi (o contenuti divergenti allo stesso URL, con la stessa rappresentazione contrattuale) a bot e utenti, tipicamente basandosi sullo User-Agent. Content negotiation significa servire la stessa risorsa in una rappresentazione diversa dichiarata dall'header del client. È HTTP da manuale. Il mittente del Accept: text/markdown sa cosa sta chiedendo; chi non lo manda riceve la rappresentazione default HTML.
Lo stato dell'arte al 23 aprile 2026
Chi manda davvero Accept: text/markdown? Ho verificato su un pool di 47 giorni di access log del mio blog. La situazione è questa:
| Agent | Version | Manda text/markdown? |
|---|---|---|
| Claude Code | 2.1.38 | Sì (q=1.0) |
| Cursor | 2.4.28 | Sì (q=1.0) |
| OpenCode | 1.2.5 | Sì (q=1.0) |
| Claude Agent SDK (fetch tool) | 0.4.x | Sì |
| GitHub Copilot Chat | 1.18 | No (solo text/html) |
| Gemini CLI | 0.9.4 | No |
| OpenAI Codex CLI | 0.8 | No |
| ChatGPT plugin retrieval | n/a | No |
| Perplexity browser | n/a | No |
Questo è il panorama agenti. Il panorama publisher è più affollato. Da settembre 2025 sette publisher tecnici servono Markdown via content negotiation direttamente on-origin senza Cloudflare: Anthropic docs, Stripe docs, Shopify engineering, Vercel, Mintlify, Redis docs, Qdrant docs. Cloudflare ha aggiunto l'edge conversion il 12 febbraio 2026 abilitando la feature di default per Pro, Business ed Enterprise. Quattro giorni prima, il 6 febbraio 2026, John Mueller di Google aveva detto pubblicamente che la pratica di creare pagine Markdown separate per LLM era "a stupid idea", posizione ripresa da Fabrice Canel di Bing (fonte). Cloudflare ha scelto il momento.
Tre approcci per servire Markdown on-origin
Hai tre strade, con trade-off diversi di dipendenza, manutenzione e controllo.
La prima è usare un pacchetto pronto. Spatie ha rilasciato il 17 febbraio 2026 spatie/laravel-markdown-response (v1.1.0 il 22 febbraio), che richiede PHP 8.4 e Laravel 12, implementa un middleware ProvideMarkdownResponse, detecta richieste via header Accept, user-agent noti (GPTBot, ClaudeBot) e suffisso .md nell'URL. Usa league/html-to-markdown 5.1+ come driver locale, opzionalmente Cloudflare Workers AI come driver remoto. Le risposte convertite sono cacheate di default. Per Symfony l'equivalente è soleinjast/symfony-markdown-response-bundle v1.0.0 del 20 febbraio 2026, richiede PHP 8.2+, Symfony 6.4/7.x/8.x, espone l'attributo #[ProvideMarkdownResponse] applicabile a classe controller o metodo, usa Vary: Accept e un'interfaccia HtmlPreprocessorInterface per rimuovere rumore (cookie banner, navbar) prima della conversione.
La seconda strada è custom middleware zero-dep. Niente Spatie, niente bundle. Quaranta-sessanta righe di codice che parsano Accept, passano l'HTML preprocessato a league/html-to-markdown, cachano il risultato. Vantaggio: nessuna dipendenza esterna oltre alla libreria di conversione, controllo totale. Svantaggio: devi gestire tu edge case (multipart, content-length, cache invalidation).
La terza strada è sidecar endpoint tipo /docs/{slug}/markdown.md. La peggiore. Duplica URL, conforma esattamente alla critica di Mueller, e costringe i tuoi agenti interni a conoscere due mappe di routing. La menziono solo per scongiurarla.
Io uso l'approccio custom su tutto quello che non è blog-pubblico, perché i miei agenti sanno cosa chiedere e voglio il controllo del preprocessing. Spatie/soleinjast sui blog pubblici, perché la community mantiene le edge case.
Implementazione Laravel 12 passo per passo
Partiamo dal custom middleware. Installi solo il converter:
composer require league/html-to-markdown:^5.1Poi crei il middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use League\HTMLToMarkdown\HtmlConverter;
use Symfony\Component\HttpFoundation\Response;
class NegotiateMarkdown
{
// TTL di cache per la rappresentazione markdown: un'ora
private const CACHE_TTL_SECONDS = 3600;
// Media type richiesto dagli agenti AI
private const MARKDOWN_MIME = 'text/markdown';
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Converto solo se: status 200, Content-Type HTML, client ha chiesto markdown
if (! $this->shouldConvert($request, $response)) {
return $this->withVaryHeader($response);
}
$html = $response->getContent();
if ($html === false || $html === '') {
return $this->withVaryHeader($response);
}
// Chiave di cache: hash del path + query + hash del body sorgente (invalida su cambio contenuto)
$cacheKey = 'md:' . sha1($request->fullUrl() . '|' . sha1($html));
$markdown = Cache::remember(
$cacheKey,
self::CACHE_TTL_SECONDS,
fn () => $this->convert($html)
);
// Stima token grezza basata su 4 char/token (euristica cl100k_base)
$estimatedTokens = (int) ceil(strlen($markdown) / 4);
return response($markdown, 200)
->header('Content-Type', self::MARKDOWN_MIME . '; charset=utf-8')
->header('Vary', 'Accept')
->header('X-Markdown-Tokens', (string) $estimatedTokens)
->header('Content-Signal', 'ai-train=yes, search=yes, ai-input=yes');
}
private function shouldConvert(Request $request, Response $response): bool
{
if ($response->getStatusCode() !== 200) {
return false;
}
$contentType = strtolower((string) $response->headers->get('Content-Type', ''));
if (! str_contains($contentType, 'text/html')) {
return false;
}
return $request->accepts(self::MARKDOWN_MIME);
}
private function withVaryHeader(Response $response): Response
{
// Vary: Accept SEMPRE, anche quando servo HTML, altrimenti la cache edge serve
// la risposta sbagliata al client successivo con Accept diverso
$response->headers->set('Vary', 'Accept', false);
return $response;
}
private function convert(string $html): string
{
// Preprocessing: rimuovo rumore strutturale prima della conversione
$cleaned = $this->stripNoise($html);
$converter = new HtmlConverter([
'strip_tags' => true,
'remove_nodes' => 'script style nav aside footer',
'hard_break' => true,
'header_style' => 'atx',
]);
return trim($converter->convert($cleaned));
}
private function stripNoise(string $html): string
{
// Rimuovo blocchi tipici non-content via regex conservativa
$patterns = [
'#<script[^>]*>.*?</script>#si',
'#<style[^>]*>.*?</style>#si',
'#<!--(?!\[if).*?-->#s',
];
return preg_replace($patterns, '', $html) ?? $html;
}
}Registri il middleware in bootstrap/app.php con alias negotiate.markdown e lo applichi al gruppo di route che vuoi esporre:
use App\Http\Middleware\NegotiateMarkdown;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'negotiate.markdown' => NegotiateMarkdown::class,
]);
})
->create();Route group:
Route::middleware(['negotiate.markdown'])->group(function () {
Route::get('/blog/post/{slug}', [BlogController::class, 'show']);
Route::get('/docs/{page}', [DocsController::class, 'show']);
});Test diretto:
# Richiesta HTML classica
curl -I https://example.test/blog/post/foo
# Content-Type: text/html; charset=UTF-8
# Vary: Accept
# Richiesta Markdown
curl -H 'Accept: text/markdown' https://example.test/blog/post/foo
# Content-Type: text/markdown; charset=utf-8
# X-Markdown-Tokens: 842Il middleware rispetta il principio di singola responsabilità: converte quando richiesto, propaga Vary: Accept sempre (anche sulle risposte HTML, per non rompere le cache edge), non tocca il controller che continua a renderizzare HTML Blade come ha sempre fatto.
Implementazione Symfony 7.2 passo per passo
Stesso pattern, traduzione idiomatica. Install:
composer require league/html-to-markdown:^5.1Event subscriber che intercetta kernel.response:
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use League\HTMLToMarkdown\HtmlConverter;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\AcceptHeader;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class MarkdownNegotiationSubscriber implements EventSubscriberInterface
{
private const MARKDOWN_MIME = 'text/markdown';
private const CACHE_TTL_SECONDS = 3600;
public function __construct(
private readonly CacheItemPoolInterface $cache,
) {
}
public static function getSubscribedEvents(): array
{
// Priority negativa: eseguo dopo altri subscriber che potrebbero aver già
// impostato header di sicurezza o compressione
return [KernelEvents::RESPONSE => ['onKernelResponse', -10]];
}
public function onKernelResponse(ResponseEvent $event): void
{
$request = $event->getRequest();
$response = $event->getResponse();
// Vary: Accept sempre, anche quando servo HTML
$response->setVary('Accept', false);
if (! $this->shouldConvert($request->headers->get('Accept', ''), $response)) {
return;
}
$html = $response->getContent();
if (! is_string($html) || $html === '') {
return;
}
$cacheKey = 'md_' . sha1($request->getUri() . '|' . sha1($html));
$item = $this->cache->getItem($cacheKey);
if (! $item->isHit()) {
// Miss: converto e cacho
$markdown = $this->convert($html);
$item->set($markdown);
$item->expiresAfter(self::CACHE_TTL_SECONDS);
$this->cache->save($item);
} else {
$markdown = $item->get();
}
$estimatedTokens = (int) ceil(strlen($markdown) / 4);
// Sovrascrivo completamente la risposta mantenendo status e vary
$response->setContent($markdown);
$response->headers->set('Content-Type', self::MARKDOWN_MIME . '; charset=utf-8');
$response->headers->set('X-Markdown-Tokens', (string) $estimatedTokens);
$response->headers->set('Content-Signal', 'ai-train=yes, search=yes, ai-input=yes');
}
private function shouldConvert(string $acceptHeader, Response $response): bool
{
if ($response->getStatusCode() !== Response::HTTP_OK) {
return false;
}
$contentType = strtolower((string) $response->headers->get('Content-Type', ''));
if (! str_contains($contentType, 'text/html')) {
return false;
}
// Parse dell'header Accept via componente nativo Symfony
$accept = AcceptHeader::fromString($acceptHeader);
return $accept->has(self::MARKDOWN_MIME);
}
private function convert(string $html): string
{
$converter = new HtmlConverter([
'strip_tags' => true,
'remove_nodes' => 'script style nav aside footer',
'hard_break' => true,
'header_style' => 'atx',
]);
// Pulizia minima pre-conversione
$cleaned = preg_replace(
['#<script[^>]*>.*?</script>#si', '#<style[^>]*>.*?</style>#si'],
'',
$html,
) ?? $html;
return trim($converter->convert($cleaned));
}
}Registrazione via autowiring in config/services.yaml: nessuna configurazione aggiuntiva se hai autoconfigure: true. Il subscriber viene trovato automaticamente.
Symfony\Component\HttpFoundation\AcceptHeader::fromString è la strada idiomatica: parsa q-values e gestisce edge case come */*;q=0.5 (che accetta tutto con bassa priorità). Usarlo vince su un str_contains nell'header grezzo.
Implementazione PHP vanilla (nessun framework)
Il pattern vale anche senza framework. Utile se stai esponendo documentazione statica servita da un front controller minimale o se hai legacy PHP 8.3 che non vuoi portare su Laravel solo per questo. Install del converter:
composer require league/html-to-markdown:^5.1Helper minimale (salva come lib/MarkdownNegotiator.php):
<?php
declare(strict_types=1);
namespace App\Lib;
use League\HTMLToMarkdown\HtmlConverter;
final class MarkdownNegotiator
{
private const MARKDOWN_MIME = 'text/markdown';
private const CACHE_DIR = __DIR__ . '/../var/cache/markdown';
private const CACHE_TTL_SECONDS = 3600;
public static function clientWantsMarkdown(string $acceptHeader): bool
{
// Parsing minimale: splitto su virgola e verifico presenza di text/markdown
// con q-value >= 0.1 (scarto quelli con q=0 esplicito)
if ($acceptHeader === '') {
return false;
}
foreach (explode(',', $acceptHeader) as $part) {
$segments = array_map('trim', explode(';', $part));
$mime = strtolower(array_shift($segments) ?? '');
if ($mime !== self::MARKDOWN_MIME) {
continue;
}
$quality = 1.0;
foreach ($segments as $param) {
if (str_starts_with($param, 'q=')) {
$quality = (float) substr($param, 2);
}
}
return $quality > 0.0;
}
return false;
}
public static function convertWithCache(string $url, string $html): string
{
if (! is_dir(self::CACHE_DIR)) {
mkdir(self::CACHE_DIR, 0775, true);
}
$cacheFile = self::CACHE_DIR . '/' . sha1($url . '|' . sha1($html)) . '.md';
// Cache hit valido entro TTL
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < self::CACHE_TTL_SECONDS) {
return (string) file_get_contents($cacheFile);
}
$converter = new HtmlConverter([
'strip_tags' => true,
'remove_nodes' => 'script style nav aside footer',
'hard_break' => true,
'header_style' => 'atx',
]);
$cleaned = preg_replace(
['#<script[^>]*>.*?</script>#si', '#<style[^>]*>.*?</style>#si'],
'',
$html,
) ?? $html;
$markdown = trim($converter->convert($cleaned));
// Scrittura atomica: temp file + rename per evitare lettura parziale sotto carico
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $markdown);
rename($tmp, $cacheFile);
return $markdown;
}
}Front controller (public/index.php):
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Lib\MarkdownNegotiator;
// Vary: Accept sempre. Fondamentale per la correttezza della cache intermedia.
header('Vary: Accept');
// Rendering HTML "classico" del sito (funzione tua, qui stubbata)
ob_start();
require __DIR__ . '/../views/home.php';
$html = (string) ob_get_clean();
$acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';
if (! MarkdownNegotiator::clientWantsMarkdown($acceptHeader)) {
// Default: servo HTML
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit;
}
$fullUrl = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . ($_SERVER['REQUEST_URI'] ?? '/');
$markdown = MarkdownNegotiator::convertWithCache($fullUrl, $html);
$estimatedTokens = (int) ceil(strlen($markdown) / 4);
header('Content-Type: text/markdown; charset=utf-8');
header('X-Markdown-Tokens: ' . $estimatedTokens);
header('Content-Signal: ai-train=yes, search=yes, ai-input=yes');
echo $markdown;Non è un framework, non ci sono magic service container, ma la semantica è identica ai due implementation framework. Per siti legacy PHP 8.3 con front controller semplice è l'opzione a minor cost di integrazione.
Cache differenziata tra browser e bot
Il bug più frequente in questo setup non è la conversione. È la cache. Se hai Nginx davanti al tuo PHP-FPM e stai usando proxy_cache, la chiave di cache di default è basata su host, path e query string. Non sull'header Accept. Conseguenza: il primo client che arriva decide cosa tutti gli altri riceveranno dal proxy. Se un Claude Code bussa per primo, Chrome vede Markdown. Se un Chrome bussa per primo, Claude Code vede HTML e consuma il quadruplo dei token.
La fix è cambiare la chiave di cache Nginx includendo l'header Accept:
proxy_cache_path /var/cache/nginx/zones keys_zone=md_zone:100m inactive=1h use_temp_path=off;
map $http_accept $accept_cache_key {
default "html";
"~*text/markdown" "markdown";
}
server {
location / {
proxy_cache md_zone;
proxy_cache_key "$host$uri$is_args$args|$accept_cache_key";
proxy_cache_valid 200 10m;
proxy_pass http://php_fpm_backend;
}
}Due varianti per URL, non di più. Vary: Accept grezzo renderebbe la cache key dipendente dal valore completo dell'header, che varia molto (browser Chrome manda un Accept diverso da Firefox), quindi l'hit ratio crollerebbe. La mapping html/markdown normalizza a due categorie, tagliando la combinatoria.
Su Fastly VCL il pattern è simile: hash_data(req.http.Accept ~ "text/markdown" ? "md" : "html") dentro vcl_hash. Su Cloudflare Page Rules l'equivalente è la cache key personalizzata disponibile sui piani Enterprise. Se sei su piani inferiori e vuoi mantenere Cloudflare come edge ma gestire tu la conversione, disabilita cache Cloudflare sulle route markdown e lasciala al tuo origin.
AEO: Mueller, Canel e il dibattito cloaking
Questa è la sezione dove devi stare attento, perché l'AEO hype industriale si è buttato sul tema e sta producendo contenuti fuorvianti.
Il 6 febbraio 2026, quando Lily Ray ha chiesto pubblicamente se creare pagine .md separate per LLM fosse una buona idea, John Mueller di Google ha risposto verbatim: "I'm not aware of anything in that regard. In my POV, LLMs have trained on, read and parsed, normal web pages since the beginning, it seems a given that they have no problems dealing with HTML. Why would they want to see a page that no user sees? And, if they check for equivalence, why not use HTML?". In un tono più colorito: "Converting pages to markdown is such a stupid idea." Fabrice Canel di Bing ha rincarato: "really want to double crawl load? We'll crawl anyway to check similarity. Non-user versions (crawlable AJAX and like) are often neglected, broken. Less is more in SEO!". Entrambe le dichiarazioni sono documentate da Search Engine Land del 6 febbraio 2026.
La distinzione tecnica che l'industry chiacchiera non fa quasi mai è questa. Mueller e Canel stanno criticando pagine Markdown separate a URL diverso (es. /blog/post/foo.md come route distinta servita da middleware user-agent-based). Quella pratica è classic cloaking: stesso URL concettuale, contenuto divergente basato su chi sei. La content negotiation via Accept è diversa: lo stesso URL serve rappresentazioni diverse della stessa risorsa, dichiarate dal client. È HTTP da RFC 9110. Google lo usa da anni per Accept-Language (stesso URL, risposta localizzata) senza considerarlo cloaking.
Il consulente SEO David McSweeney ha sollevato un'obiezione legittima in questo thread: se il backend riceve l'header Accept: text/markdown senza stripping, un origin malizioso potrebbe iniettare contenuto diverso per il bot rispetto all'utente, creando una "shadow web for bots". La sua preoccupazione è valida, e la risposta non è "non usare content negotiation", è "non iniettare contenuto divergente nel rendering Markdown". Se la rappresentazione Markdown è il dump semantico dell'HTML renderizzato, non hai cloaking. Se la Markdown include sezioni fantasma invisibili agli umani, hai cloaking anche se tecnicamente stai rispettando RFC 9110.
Google Search Central ha una posizione esplicita da dicembre 2025 sulle AI feature: "Non sono previsti requisiti tecnici aggiuntivi". Tradotto: non serve Markdown per essere citato da AI Overviews o AI Mode. Se la tua motivazione per implementare content negotiation è SEO/AEO, non ne hai. Se la tua motivazione è cost governance sugli agenti che integri nel tuo stack interno, ne hai eccome, ma smetti di sentirlo come "AEO trick". Non lo è.
Il lethal bug: audit checklist prima di aprire al pubblico
Prima di mettere in produzione, sei controlli operativi.
Primo. Il rendering Markdown deve essere un dump fedele dell'HTML reso pubblico, non una versione arricchita con istruzioni nascoste per agenti. Se stai tentando di iniettare un system prompt o una descrizione aggiuntiva "per gli agenti", stai costruendo cloaking.
Secondo. Il preprocessing deve rimuovere solo rumore strutturale (script, style, nav, footer, cookie banner), mai semantica (paragrafi, heading, immagini con alt text, link). Se la Markdown perde link che l'HTML ha, l'agente prenderà decisioni sbagliate sul contenuto linkato.
Terzo. Vary: Accept deve essere presente su tutte le risposte, non solo su quelle Markdown. Il bug classico è settare Vary solo sul branch markdown: CDN serve HTML a bot e Markdown a utenti, inversione perfetta.
Quarto. Le route che servono dati sensibili (/admin, /user/{id}/private) non vanno mai incluse nel middleware. Non è una questione tecnica, è una questione di blast radius: se il bot scrape una pagina di dashboard autenticata con sessione valida, la Markdown finisce in contesto LLM esterno.
Quinto. Il driver di conversione deve essere deterministico. Se usi Cloudflare Workers AI come driver remoto (opzione di spatie/laravel-markdown-response), stai aggiungendo una dipendenza esterna e una variabile di output. Per contesto RAG interno, io preferisco sempre league/html-to-markdown locale: stesso input = stesso output.
Sesto. Test di contratto. Un integration test che chiama lo stesso URL con e senza Accept: text/markdown e verifica che (a) il corpo semantico sia equivalente (stessi link, stessi heading), (b) Vary: Accept sia sempre presente, (c) lo status code sia 200 in entrambi i casi. Questo test deve vivere in CI e fallire il deploy se qualcuno cambia il middleware senza considerarlo.
Dove porta tutto questo
L'approccio "senza Cloudflare" non è una crociata contro Cloudflare. Cloudflare ha un ottimo prodotto e il toggle è comodo. L'approccio on-origin serve quando vuoi possedere il pezzo di infrastruttura che espone i tuoi asset agli agenti, perché quegli asset sono core business o perché ha senso mantenere l'unica fonte di verità della conversione dentro il tuo stack. Serve quando sei su provider non instradati da Cloudflare. Serve quando vuoi controllare il Content-Signal con logica custom (diversa per /docs rispetto a /blog, diversa per tenant B2B rispetto a pubblico). Quando niente di questo è vero, Cloudflare è la scelta razionale.
C'è un secondo livello che questo articolo non copre e che diventa rilevante nel momento in cui gli agenti che chiedono Markdown sono agenti commerciali di altre aziende: a quel punto, la domanda non è più "come li servo efficientemente", è "come li faccio pagare per l'accesso". Il 2026 ha finalmente sbloccato lo status code 402 Payment Required fermo dal 1997, con x402, L402 e Cloudflare pay-per-crawl che stanno costruendo l'infrastruttura economica del web agentico. Lo tratto nel pezzo complementare su HTTP 402 che esce domani: servire Markdown è il passo tecnico, far pagare il Markdown è il passo economico. Una volta che il tuo backend sa parlare Markdown agli agenti, puoi decidere se monetizzare quel canale o lasciarlo aperto. Tecnicamente, la content negotiation è il prerequisito per entrambe le scelte.
Se gestisci documentazione tecnica interna, una knowledge base aziendale o un blog tecnico che alimenta il tuo RAG o quello dei tuoi clienti, e vuoi capire se implementare content negotiation Markdown on-origin ha senso nel tuo stack specifico, il modulo di preventivo gratuito ti risponde in due minuti se il tuo caso rientra nel mio ambito. Sette domande, niente impegno. Nel frattempo, se vuoi approfondire come scrivo MCP server custom che consumano proprio questo tipo di endpoint Markdown, nel mio articolo sugli MCP server personalizzati per Claude Code trovi il pattern architetturale completo. La content negotiation è il layer di trasporto; gli MCP server sono il layer di orchestrazione. Insieme, smettono di essere feature e diventano infrastruttura.