Markdown serveren aan AI-agents via Accept-headers: de .NET-versie

Een trending Hacker News-idee zegt: geef AI-agents markdown in plaats van HTML, via content negotiation. Zo bouw je dat correct in ASP.NET Core, met de afwegingen waar een senior echt wakker van ligt.

Jean-Pierre Broeders

Freelance .NET Developer

27 augustus 202610 min. leestijd
Markdown serveren aan AI-agents via Accept-headers: de .NET-versie

Een kleine site haalde deze week de voorpagina van Hacker News met een bedrieglijk simpel voorstel: als een AI-agent je pagina opvraagt, geef hem dan markdown terug in plaats van HTML. Zelfde URL, andere representatie, bepaald door de Accept-header. De uitleg op acceptmarkdown.com noemt het content negotiation, en dat is precies wat het is. De HN-discussie liep zoals dat soort draadjes altijd lopen: de ene helft vindt de elegantie prachtig, de andere helft vraagt wie er vandaag eigenlijk Accept: text/markdown verstuurt.

Beide reacties kloppen, en juist dat gat is het interessante. Het idee is schone HTTP. De praktijk is rommeliger. Ik loop er doorheen zoals het eruitziet als je het in ASP.NET Core bouwt, waar de scherpe randjes zitten, en of het je tijd waard is.

Waarom iemand dit wil

Een LLM die je blogpost ophaalt, wil je navigatie niet. Ook je cookiebanner niet, je drie "gerelateerde artikelen"-balken niet en die elf kilobyte aan Tailwind-classes rondom veertig woorden echte tekst al helemaal niet. Dat kost allemaal tokens, en tokens zijn contextbudget dat het model liever aan je inhoud besteedt. Strip de pagina terug naar markdown en je stuurt een fractie van de bytes, met bijna geen structurele ruis waar een retrieval-pijplijn over struikelt.

Het argument houdt dus stand. De vraag is hoe de agent laat weten dat hij de gestripte versie wil, en daar komt Accept binnen. Het is de oudste truc in HTTP: één URL, meerdere representaties, de client geeft een voorkeur op, de server kiest. We doen dit al jaren met application/json tegenover application/xml. Markdown is gewoon nog een media type. Het heeft zelfs een registratie, text/markdown, uit RFC 7763.

De naïeve versie, en waarom die bijna klopt

Dit is het kortste dat zou kunnen werken in een minimal API:

app.MapGet("/blog/{slug}", async (string slug, HttpRequest req, IContentStore store) =>
{
    var post = await store.FindBySlug(slug);
    if (post is null)
        return Results.NotFound();

    if (req.Headers.Accept.ToString().Contains("text/markdown", StringComparison.OrdinalIgnoreCase))
        return Results.Text(post.Markdown, "text/markdown; charset=utf-8");

    return Results.Content(RenderHtml(post), "text/html; charset=utf-8");
});

Dit werkt in een demo en het gaat je in productie bijten. De Accept-header is geen string die je even doorzoekt. Het is een gerangschikte lijst met quality-waarden, zoals text/html,application/xhtml+xml,text/markdown;q=0.9,*/*;q=0.8. Een botte Contains zegt ja tegen een client die markdown maar mild verkiest en eigenlijk liever HTML had gehad. Bovendien matcht hij op */*, en dat sturen elke browser en elke curl mee. Zo geef je markdown aan mensen die om een webpagina vroegen.

Parse hem fatsoenlijk. ASP.NET Core levert het gereedschap in Microsoft.Net.Http.Headers:

using Microsoft.Net.Http.Headers;

static bool PrefersMarkdown(HttpRequest req)
{
    var media = req.Headers.GetCommaSeparatedValues("Accept")
        .Select(v => MediaTypeHeaderValue.TryParse(v, out var mt) ? mt : null)
        .Where(mt => mt is not null)
        .Select(mt => mt!)
        .ToList();

    double QualityFor(string type) => media
        .Where(mt => mt.MatchesMediaType(type))
        .Select(mt => mt.Quality ?? 1.0)
        .DefaultIfEmpty(0)
        .Max();

    var markdown = QualityFor("text/markdown");
    var html = QualityFor("text/html");

    return markdown > 0 && markdown >= html;
}

Nu krijgt een client alleen markdown als hij expliciet text/markdown heeft genoemd én HTML niet hoger rangschikte. Een browser die text/html,...,*/*;q=0.8 stuurt, valt er nooit in, want text/markdown scoort nul. Die ene functie is de hele feature, en het is precies het stuk dat de demoversie fout deed.

Vergeet de Vary-header niet

Zodra één URL twee verschillende bodies teruggeeft afhankelijk van een request-header, moet elke cache in het pad dat weten. Zonder dat cachet een CDN wat de eerste bezoeker kreeg, en serveert het een uur lang markdown aan een browser of HTML aan een agent. De fix is één regel:

app.Use(async (context, next) =>
{
    context.Response.Headers.Vary = "Accept";
    await next();
});

Er zit een addertje onder, dat ook in de HN-draad opdook en dat ik niet zou overslaan. Vary: Accept betekent dat de cache sleutelt op de volledige Accept-string, en browsers sturen lange, bijna unieke Accept-strings. Je cache hit rate zakt in elkaar, want in de praktijk krijgt elke browser zijn eigen cache-entry. Op een druk CDN zijn dat echte kosten.

Draai je achter Cloudflare, Fastly of een nginx-edge, dan is de verstandige zet om Accept te normaliseren voordat het de cache raakt. Vouw de oneindige variatie aan browser-headers terug tot twee bakjes, markdown of niet, en vary op één afgeleide header. In nginx is dat een map plus een proxy_set_header, op Cloudflare een kleine Worker. De origin-logica blijft gelijk, de cache blijft nuttig.

De MVC-manier, als je al controllers hebt

Zit je API in controllers en niet in minimal endpoints, bouw de negotiation dan niet met de hand. Schrijf een output formatter en laat het framework de content negotiation draaien die het voor JSON al doet:

public sealed class MarkdownOutputFormatter : TextOutputFormatter
{
    public MarkdownOutputFormatter()
    {
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/markdown"));
        SupportedEncodings.Add(Encoding.UTF8);
    }

    protected override bool CanWriteType(Type? type) =>
        typeof(IMarkdownSerializable).IsAssignableFrom(type);

    public override Task WriteResponseBodyAsync(
        OutputFormatterWriteContext context, Encoding selectedEncoding)
    {
        var payload = (IMarkdownSerializable)context.Object!;
        return context.HttpContext.Response.WriteAsync(payload.ToMarkdown(), selectedEncoding);
    }
}

Registreer hem, en zet de twee opties aan die ASP.NET Core naar de client laten luisteren in plaats van altijd zijn default terug te geven:

builder.Services.AddControllers(options =>
{
    options.RespectBrowserAcceptHeader = true;
    options.ReturnHttpNotAcceptable = true;
    options.OutputFormatters.Insert(0, new MarkdownOutputFormatter());
});

ReturnHttpNotAcceptable geeft je de correcte 406 als een client per se een media type wil dat je niet kunt produceren, en dat is het eerlijke antwoord in plaats van stilletjes downgraden. RespectBrowserAcceptHeader weerhoudt het framework ervan de header te negeren bij browser-achtige requests. Je action geeft een model terug dat weet hoe het zichzelf als markdown rendert, en het framework kiest per request HTML of markdown. Geen if in je controller.

De ongemakkelijke vraag: wie stuurt die header eigenlijk?

Hier zet ik mijn senior-pet op en rem ik af. De header-aanpak is technisch correct en toch zou ik er mijn contentstrategie niet alleen op durven bouwen, want er stuurt nu vrijwel niets Accept: text/markdown. De meeste crawlers, de meeste agent-frameworks en de meeste "lees deze URL even voor me"-tools halen op met een default Accept of helemaal geen. Bouw de perfecte negotiation-laag en vandaag raakt bijna niemand de markdown-tak.

Daarom pleitte een deel van de HN-draad ervoor om in plaats daarvan naar de User-Agent te kijken: herken GPTBot, ClaudeBot, PerplexityBot en consorten, en serveer die markdown ongeacht Accept. Het werkt, en ik snap het pragmatisme, maar ik zou het als vangnet houden en niet als fundament. User-Agent-strings liegen, ze veranderen zonder aankondiging, en een allowlist van botnamen onderhouden is precies het soort klusje dat rot. De Accept-header is het standaard-correcte signaal, en het kost je niets om hem nu al te honoreren, zodat het gewoon werkt op de dag dat agents hem wél gaan sturen.

Een tussenweg die ik mooi vind: honoreer Accept als primair signaal, houd een korte, config-gedreven User-Agent-allowlist als bewuste override, en bied de markdown ook aan op een voorspelbare alternatieve URL, zeg /blog/mijn-post.md, zodat alles de schone versie kan pakken zonder enige negotiation. Drie ingangen, één bron van waarheid. Die laatste optie past ook natuurlijk bij een llms.txt-bestand, de andere helft van dit gesprek: Accept regelt het formaat per request, llms.txt en .md-URL's regelen de vindbaarheid.

// Bewuste override, gedreven door config, geen gehardcodeerde lijst diep in de code.
static bool IsKnownAgent(HttpRequest req, AgentOptions opts)
{
    var ua = req.Headers.UserAgent.ToString();
    return opts.MarkdownAgents.Any(name => ua.Contains(name, StringComparison.OrdinalIgnoreCase));
}

Wat ik echt zou uitleveren

Serveer per pagina één canonieke markdown-body, gegenereerd uit dezelfde bron waaruit je HTML rendert, zodat de twee nooit uit elkaar lopen. Bepaal het formaat met een echte Accept-parser die q-waarden respecteert. Zet Vary: Accept en normaliseer Accept daarna aan de edge zodat je cache overleeft. Voeg een .md-URL toe en een kleine, config-gedreven User-Agent-override voor de agents die nog niet bij zijn. En meet het: log welke tak elk request nam, want als de markdown-tak een kwartaal lang op nul procent blijft staan, heb je je antwoord over of je hem wilt blijven onderhouden.

Het voorstel dat de voorpagina haalde, is goede HTTP-hygiëne en ik ben blij dat het aandacht krijgt. Bouw alleen de volwassen versie, niet de Contains("text/markdown")-versie, want het verschil tussen die twee is of elke browser op internet per ongeluk je rauwe markdown geserveerd krijgt.

Veelgestelde vragen over markdown serveren via content negotiation

Is text/markdown een echt media type dat ik mag gebruiken? Ja, het is als text/markdown geregistreerd in RFC 7763, dus het is een geldige waarde om in Accept te sturen en als je Content-Type te zetten. Er is een optionele variant-parameter voor de specifieke smaak, maar voor het serveren van artikel-bodies heb je die zelden nodig en volstaat gewoon text/markdown; charset=utf-8.

Waarom niet gewoon de User-Agent sniffen en de Accept-header helemaal overslaan? Omdat User-Agent-strings onbetrouwbaar zijn, zonder waarschuwing veranderen en je dwingen een allowlist van botnamen te onderhouden die veroudert. De Accept-header is het standaard-correcte signaal dat je vandaag gratis kunt honoreren en dat vanzelf werkt zodra agents hem sturen, dus houd User-Agent-sniffing als bewuste override en niet als je hoofdmechanisme.

Verpest Vary: Accept mijn CDN-cache? Dat kan, want browsers sturen lange en bijna unieke Accept-strings, dus als je de cache op de rauwe header sleutelt krijgt elke bezoeker praktisch zijn eigen entry. Normaliseer Accept aan de edge naar twee bakjes, markdown of niet, en vary op die ene afgeleide header, zodat de origin-logica gelijk blijft en de cache nuttig blijft.

Moet ik 406 teruggeven als ik het gevraagde type niet kan produceren? Ja, als een client een Accept stuurt die je echt niet kunt bedienen is een 406 Not Acceptable het eerlijke antwoord, en in ASP.NET Core zet je dat aan met ReturnHttpNotAcceptable = true. Voor browsers gebeurt dit zelden omdat zij */* sturen, dat door elke representatie wordt bevredigd, dus de 406 duikt alleen op bij clients die om iets specifieks en onmogelijks vragen.

Hoe verhoudt dit zich tot llms.txt? Ze lossen verschillende helften van hetzelfde probleem op: Accept-gebaseerde negotiation bepaalt het formaat van een pagina waarvan je de URL al kent, terwijl llms.txt en voorspelbare .md-URL's agents helpen je schone content überhaupt te vinden en op te halen. Door zowel een markdown-representatie als een vindbaarheidsmechanisme uit te leveren dek je meer echte clients af dan met een van de twee.

Verder lezen


Conclusie: Behandel "markdown serveren aan agents" als een content-negotiation-feature, niet als een salontruc. Parse Accept met q-waarden, zet en normaliseer Vary, voeg een .md-URL als vangnet toe, en meet hoe vaak de markdown-tak echt afgaat voordat je er meer in stopt.

Bronnen: Serve Markdown to AI Agents with Accept Headers · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op