Markdown for Agents
Immer mehr Webinhalte werden nicht mehr von Menschen per Google gesucht, sondern von KI-Agenten direkt abgerufen. Das Problem: HTML ist für Agenten unnötig aufwendig zu parsen.
Einleitung
KI-Agenten werden zunehmend zum primären "Leser" von Webinhalten – und sie bevorzugen Markdown statt HTML. Dieser Artikel richtet sich an Webentwickler und DevOps-Engineers, die ihre Webseiten für KI-Agenten optimieren möchten, ohne auf einen bestimmten Hoster angewiesen zu sein. Er beleuchtet zwei Implementierungsansätze – anwendungsspezifisch und proxy-basiert –, mit einem konkreten Codebeispiel in .NET und einer Beispielkonfiguration für Nginx.
Motivation
In einem Blogartikel "Introducing Markdown for Agents" stellt Cloudflare – als einer der großen Hostinganbieter – ein neues Feature für Webseiten vor, das der Tatsache Rechnung trägt, dass immer mehr Content im Internet nicht mehr von Menschen, sondern von KI-Agenten (aka Bots) abgerufen wird. Ausgangspunkt dabei ist der Fakt, dass das Parsen von HTML-Content für KI-Agenten relativ aufwendig (sprich tokenintensiv) ist, da eine Webseite naturgemäß viele Elemente enthält, die eher layout- als inhaltsrelevant sind. Demgegenüber lassen sich Markdown-Inhalte durch KI-Agenten wesentlich "tokenärmer" und damit kostengünstiger parsen, da der Fokus hier klar auf dem Inhalt liegt. Die im Artikel geäußerte Kernidee ist daher das (automatische) Ausliefern von Markdown statt HTML, wenn im HTTP-Request-Header Accept der Wert "text/markdown" enthalten ist, Beispiel:
curl https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/ \
-H "Accept: text/markdown"
Dieses Feature steht für bei Cloudflare gehostete Webseiten lt. Dokumentation automatisch zur Verfügung. Da die Idee durchaus bestechend ist – zukünftig werden Inhalte wohl immer weniger (per Google vermittelt) "manuell" abgerufen und im Gegenzug eher per KI-Chatinterface durch den Agenten recherchiert –, erhebt sich die Frage, wie man nicht bei Cloudflare gehostete Seiten mit diesem Feature ausstatten könnte. Dies erscheint auch deshalb wichtig, weil es künftig statt dem "Google Ranking" eine "KI-Freundlichkeit" der Webseite sein könnte, die für die Reichweite des eigenen Inhalts entscheidend ist. Dabei spielt auch der "content-signal"-Antwort-Header eine Rolle, mit dem das Verhalten des aufrufenden Agenten näher beeinflusst werden kann:
content-signal: ai-train=yes, search=yes, ai-input=yes
Ansätze zur Implementierung
Nachfolgend eine Betrachtung der Möglichkeiten, die zur Implementierung eines vergleichbaren Features prinzipiell zur Verfügung stehen, ohne sich dabei auf den Hoster verlassen zu müssen.
Anwendungsspezifische Implementierung
Diese Lösung ist speziell interessant, wenn viele Inhalte "sowieso" im Markdown-Format vorliegen und – ohne jeden Konvertierungsschritt – direkt bei Erkennen des "Accept: text/markdown"-Headers ausgeliefert werden können. Dies ist z.B. dann der Fall, wenn die "inhaltslastigen" Seiten einer Website per Markdown-Content aus einem Headless CMS wie Directus generiert werden. Dynamischer Inhalt der Website könnte entsprechend "on the fly" oder pre-generiert im Markdown-Format bereitgestellt werden.
Vorteile
- Hohe Inhaltsgenauigkeit erreichbar, da volle webseiten-spezifische Kontrolle über den Konvertierungsprozess.
Nachteile
- Spezielle Umsetzung per Webseite erforderlich (kann aber bei einheitlichem Tech-Stack durch eigene, geteilte Bibliotheken aufgefangen werden).
Beispielumsetzung
Hier ein Beispiel für eine kürzlich realisierte Website, die ihre Inhalte aus einem Directus Headless CMS bezieht, in Form einer .NET Middleware. Inhalte, die im CMS verfügbar sind, werden direkt aus dem CMS geladen (TryResolveCmsContent), andere Seiten werden per ReverseMarkdown-Bibliothek nach dem Rendern der fertigen Seite "on the fly" konvertiert. Bitte beachten, dass es sich um eine Beispielimplementierung ohne Anspruch auf Produktionstauglichkeit handelt.
///-------------------------------------------------------------------------------------------------
/// <summary> Middleware that serves page content as Markdown when the request includes
/// Accept: text/markdown. Follows the Cloudflare "Markdown for Agents" proposal. </summary>
///-------------------------------------------------------------------------------------------------
internal sealed class MarkdownForAgentsMiddleware(
RequestDelegate next,
MarkdownForAgentsOptions options,
ILogger<MarkdownForAgentsMiddleware> logger)
{
///-------------------------------------------------------------------------------------------------
/// <summary> The next middleware in the pipeline. </summary>
///-------------------------------------------------------------------------------------------------
private readonly RequestDelegate mNext = next;
///-------------------------------------------------------------------------------------------------
/// <summary> The middleware configuration options. </summary>
///-------------------------------------------------------------------------------------------------
private readonly MarkdownForAgentsOptions mOptions = options;
///-------------------------------------------------------------------------------------------------
/// <summary> The logger instance. </summary>
///-------------------------------------------------------------------------------------------------
private readonly ILogger<MarkdownForAgentsMiddleware> mLogger = logger;
///-------------------------------------------------------------------------------------------------
/// <summary> The ReverseMarkdown converter instance (thread-safe singleton). </summary>
///-------------------------------------------------------------------------------------------------
private readonly Converter mConverter = new(new Config
{
UnknownTags = Config.UnknownTagsOption.Bypass,
RemoveComments = true,
GithubFlavored = true,
SmartHrefHandling = true
});
///-------------------------------------------------------------------------------------------------
/// <summary> Known CMS content categories mapped from URL path segments. </summary>
///-------------------------------------------------------------------------------------------------
private static readonly FrozenDictionary<String, String> sCmsLanguages =
new Dictionary<String, String>(StringComparer.OrdinalIgnoreCase)
{
{ "de", "Deutsch" },
{ "en", "English" }
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
///-------------------------------------------------------------------------------------------------
/// <summary> Processes an HTTP request, returning Markdown content for AI agents. </summary>
///
/// <param name="ctx"> The HTTP context. </param>
///
/// <returns> A task representing the asynchronous operation. </returns>
///-------------------------------------------------------------------------------------------------
public async Task InvokeAsync(HttpContext ctx)
{ // check requirements
ArgumentNullException.ThrowIfNull(ctx);
// Quick exits - pass through to normal pipeline
if (!HttpMethods.IsGet(ctx.Request.Method) && !HttpMethods.IsHead(ctx.Request.Method))
{
await mNext(ctx).ConfigureAwait(false);
return;
}
if (!ClientWantsMarkdown(ctx))
{ // Client doesn't want markdown - skip processing
await mNext(ctx).ConfigureAwait(false);
return;
}
if (ShouldSkipPath(ctx.Request.Path))
{ // Path which should be skipped - pass through to normal pipeline
await mNext(ctx).ConfigureAwait(false);
return;
}
// Phase 1: Try CMS content (short-circuit, no Razor execution needed)
var (language, slug) = TryResolveCmsContent(ctx.Request.Path);
if (language != null && slug != null)
{ // get your content service here
var cacheService = ctx.RequestServices.GetService<IPageCacheService>();
if (cacheService != null)
{
try
{ // Attempt to get page content from cache or other backing source
var page = await cacheService.GetOrSetPageAsync(language, slug, ctx.RequestAborted).ConfigureAwait(false);
if (!String.IsNullOrWhiteSpace(page?.Text))
{
mLogger.LogServingCmsMarkdown(language, slug);
await WriteMarkdownResponse(ctx, page.Text).ConfigureAwait(false);
return;
}
}
catch (Exception ex)
{
mLogger.LogCmsMarkdownFailed(ex, language, slug, ex.Message);
}
}
}
// Phase 2: HTML fallback - let Razor render, then convert
var originalBody = ctx.Response.Body;
using var buffer = new MemoryStream();
ctx.Response.Body = buffer;
try
{
await mNext(ctx).ConfigureAwait(false);
// Only convert 200 OK HTML responses
if (ctx.Response.StatusCode == 200
&& ctx.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true)
{
buffer.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(buffer, Encoding.UTF8, leaveOpen: true);
var html = await reader.ReadToEndAsync(ctx.RequestAborted).ConfigureAwait(false);
var content = ExtractMainContent(html);
if (!String.IsNullOrWhiteSpace(content))
{
var markdown = mConverter.Convert(content);
ctx.Response.Body = originalBody;
// Clear Razor-set headers before writing markdown response
ctx.Response.Headers.ContentType = default;
ctx.Response.Headers.ContentLength = default;
mLogger.LogServingHtmlFallbackMarkdown(ctx.Request.Path);
await WriteMarkdownResponse(ctx, markdown).ConfigureAwait(false);
return;
}
}
// Non-200 or non-HTML or extraction failed: copy buffer to original
buffer.Seek(0, SeekOrigin.Begin);
ctx.Response.Body = originalBody;
await buffer.CopyToAsync(originalBody, ctx.RequestAborted).ConfigureAwait(false);
}
catch (Exception ex)
{ // Fail open: restore original stream, log warning
mLogger.LogMarkdownFallbackFailed(ex, ex.Message);
ctx.Response.Body = originalBody;
if (!ctx.Response.HasStarted)
{ // Try to copy whatever was buffered
buffer.Seek(0, SeekOrigin.Begin);
await buffer.CopyToAsync(originalBody, ctx.RequestAborted).ConfigureAwait(false);
}
}
}
///-------------------------------------------------------------------------------------------------
/// <summary> Determines whether the client is requesting Markdown via the Accept header. </summary>
///
/// <param name="ctx"> The HTTP context. </param>
///
/// <returns> True if the client accepts text/markdown with sufficient quality. </returns>
///-------------------------------------------------------------------------------------------------
private Boolean ClientWantsMarkdown(HttpContext ctx)
{ // Simplified: basic Accept check (not a full RFC-compliant parser)
var acceptHeader = ctx.Request.Headers.Accept.ToString();
if (String.IsNullOrWhiteSpace(acceptHeader))
{
return false;
}
// Check for text/markdown
if (acceptHeader.Contains("text/markdown", StringComparison.OrdinalIgnoreCase))
{
return ParseQValue(acceptHeader, "text/markdown") > 0;
}
// Check for legacy text/x-markdown
if (mOptions.AcceptLegacyTextXMarkdown
&& acceptHeader.Contains("text/x-markdown", StringComparison.OrdinalIgnoreCase))
{
return ParseQValue(acceptHeader, "text/x-markdown") > 0;
}
return false;
}
///-------------------------------------------------------------------------------------------------
/// <summary> Parses the quality value for a specific media type from an Accept header.
/// Uses ReadOnlySpan/MemoryExtensions.Split to avoid allocations on the hot path. </summary>
///
/// <param name="acceptHeader"> The full Accept header value. </param>
/// <param name="mediaType"> The media type to find the quality value for. </param>
///
/// <returns> The quality value (0.0 to 1.0), defaulting to 1.0 if not specified. </returns>
///-------------------------------------------------------------------------------------------------
private static Double ParseQValue(ReadOnlySpan<Char> acceptHeader, ReadOnlySpan<Char> mediaType)
{
foreach (var range in acceptHeader.Split(','))
{
var part = acceptHeader[range].Trim();
if (!part.StartsWith(mediaType, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Check for ;q= parameter
var qIndex = part.IndexOf(";q=", StringComparison.OrdinalIgnoreCase);
if (qIndex < 0)
{
return 1.0; // Default quality is 1.0
}
var qValue = part[(qIndex + 3)..].Trim();
if (Double.TryParse(qValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var q))
{
return q;
}
return 1.0;
}
return 0.0;
}
///-------------------------------------------------------------------------------------------------
/// <summary> Determines whether the given path should be skipped by the middleware. </summary>
///
/// <param name="path"> The request path. </param>
///
/// <returns> True if the path should be skipped. </returns>
///-------------------------------------------------------------------------------------------------
private Boolean ShouldSkipPath(PathString path)
{
var p = path.Value;
if (String.IsNullOrEmpty(p))
{
return false;
}
// Skip known prefixes
if (mOptions.SkipPathPrefixes.Any(prefix => p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
// Skip file extensions (static files)
var lastSegment = p.Split('/').LastOrDefault();
if (lastSegment?.Contains('.', StringComparison.Ordinal) == true)
{
return true;
}
return false;
}
///-------------------------------------------------------------------------------------------------
/// <summary> Attempts to resolve a CMS content language and slug from the request path. </summary>
///
/// <param name="path"> The request path. </param>
///
/// <returns> A tuple of (language, slug), both null if the path does not match CMS content. </returns>
///-------------------------------------------------------------------------------------------------
private static (String? Language, String? Slug) TryResolveCmsContent(PathString path)
{
var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments is not { Length: 2 })
{
return (null, null);
}
if (!sCmsLanguages.TryGetValue(segments[0], out var language))
{
return (null, null);
}
var slug = segments[1];
if (slug.Contains('.', StringComparison.Ordinal) || slug.Contains('\\', StringComparison.Ordinal))
{
return (null, null);
}
return (language, slug);
}
///-------------------------------------------------------------------------------------------------
/// <summary> Extracts the main page content between the content markers in rendered HTML. </summary>
///
/// <param name="html"> The full HTML page content. </param>
///
/// <returns> The extracted content between markers, or null if markers not found. </returns>
///-------------------------------------------------------------------------------------------------
private String? ExtractMainContent(String html)
{
var startIdx = html.IndexOf(mOptions.ContentStartMarker, StringComparison.Ordinal);
var endIdx = html.IndexOf(mOptions.ContentEndMarker, StringComparison.Ordinal);
if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx)
{
return null;
}
return html.Substring(startIdx + mOptions.ContentStartMarker.Length, endIdx - startIdx - mOptions.ContentStartMarker.Length).Trim();
}
///-------------------------------------------------------------------------------------------------
/// <summary> Writes a Markdown response with appropriate headers. </summary>
///
/// <param name="ctx"> The HTTP context. </param>
/// <param name="markdown"> The Markdown content to write. </param>
///
/// <returns> A task representing the asynchronous operation. </returns>
///-------------------------------------------------------------------------------------------------
private async Task WriteMarkdownResponse(HttpContext ctx, String markdown)
{
// ETag + conditional 304
if (mOptions.SetWeakETag)
{
var etag = CreateWeakETag(markdown);
ctx.Response.Headers[HeaderNames.ETag] = etag;
if (ctx.Request.Headers.IfNoneMatch.ToString() == etag)
{ // Simplified: exact match only (does not handle lists, weak tags, or "*")
SetCommonHeaders(ctx);
ctx.Response.StatusCode = StatusCodes.Status304NotModified;
return;
}
}
SetCommonHeaders(ctx);
ctx.Response.StatusCode = StatusCodes.Status200OK;
ctx.Response.ContentType = "text/markdown; charset=utf-8";
if (HttpMethods.IsHead(ctx.Request.Method))
{
return;
}
await ctx.Response.WriteAsync(markdown, Encoding.UTF8, ctx.RequestAborted).ConfigureAwait(false);
}
///-------------------------------------------------------------------------------------------------
/// <summary> Sets common response headers for Markdown responses. </summary>
///
/// <param name="ctx"> The HTTP context. </param>
///-------------------------------------------------------------------------------------------------
private void SetCommonHeaders(HttpContext ctx)
{
ctx.Response.Headers["Content-Signal"] = mOptions.ContentSignalHeaderValue;
MergeVary(ctx, "Accept");
if (mOptions.SetNoTransform)
{
ctx.Response.Headers[HeaderNames.CacheControl] = "no-transform";
}
}
///-------------------------------------------------------------------------------------------------
/// <summary> Merges a value into the Vary response header without duplicating. </summary>
///
/// <param name="ctx"> The HTTP context. </param>
/// <param name="value"> The value to add to the Vary header. </param>
///-------------------------------------------------------------------------------------------------
private static void MergeVary(HttpContext ctx, String value)
{
var existing = ctx.Response.Headers[HeaderNames.Vary].ToString();
if (String.IsNullOrEmpty(existing))
{
ctx.Response.Headers[HeaderNames.Vary] = value;
return;
}
if (existing.Contains(value, StringComparison.OrdinalIgnoreCase))
{
return;
}
ctx.Response.Headers[HeaderNames.Vary] = $"{existing}, {value}";
}
///-------------------------------------------------------------------------------------------------
/// <summary> Creates a weak ETag from the SHA256 hash of the content. </summary>
///
/// <param name="content"> The content to hash. </param>
///
/// <returns> A weak ETag string in the format W/"base64hash". </returns>
///-------------------------------------------------------------------------------------------------
private static String CreateWeakETag(String content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
var base64 = System.Convert.ToBase64String(bytes);
return $"W/\"{base64}\"";
}
}
#region LoggerMessages
internal static partial class MarkdownForAgentsLoggerMessages
{
[LoggerMessage(EventId = 200, Level = LogLevel.Debug, Message = "Serving CMS markdown for {Language}/{Slug}")]
public static partial void LogServingCmsMarkdown(this ILogger logger, String language, String slug);
[LoggerMessage(EventId = 201, Level = LogLevel.Warning, Message = "CMS markdown retrieval failed for {Language}/{Slug}: {Error}")]
public static partial void LogCmsMarkdownFailed(this ILogger logger, Exception ex, String language, String slug, String error);
[LoggerMessage(EventId = 202, Level = LogLevel.Debug, Message = "Serving HTML-to-Markdown fallback for {Path}")]
public static partial void LogServingHtmlFallbackMarkdown(this ILogger logger, String path);
[LoggerMessage(EventId = 203, Level = LogLevel.Warning, Message = "Markdown-for-agents fallback failed, serving HTML: {Error}")]
public static partial void LogMarkdownFallbackFailed(this ILogger logger, Exception ex, String error);
}
#endregion
Hier noch der Code zum Registrieren der Middleware:
///-------------------------------------------------------------------------------------------------
/// <summary> Registers the Markdown-for-Agents middleware. Place before MapRazorPages(). </summary>
///
/// <param name="app"> The web application. </param>
/// <param name="configure"> Optional configuration callback. </param>
///
/// <returns> The web application for chaining. </returns>
///-------------------------------------------------------------------------------------------------
public static WebApplication UseMarkdownForAgents(
this WebApplication app,
Action<MarkdownForAgentsOptions>? configure = null)
{ // check requirements
ArgumentNullException.ThrowIfNull(app);
var options = new MarkdownForAgentsOptions();
configure?.Invoke(options);
app.UseMiddleware<MarkdownForAgentsMiddleware>(options);
return app;
}
Die Konfigurationsklasse (bitte auf eigene Bedürfnisse anpassen):
///-------------------------------------------------------------------------------------------------
/// <summary> Configuration options for the Markdown-for-Agents middleware. </summary>
///-------------------------------------------------------------------------------------------------
internal sealed class MarkdownForAgentsOptions
{
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets the Content-Signal header value. </summary>
///-------------------------------------------------------------------------------------------------
public String ContentSignalHeaderValue { get; set; } = "ai-train=yes, search=yes, ai-input=yes";
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets a value indicating whether to accept the legacy text/x-markdown type. </summary>
///-------------------------------------------------------------------------------------------------
public Boolean AcceptLegacyTextXMarkdown { get; set; } = true;
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets a value indicating whether to set Cache-Control: no-transform. </summary>
///-------------------------------------------------------------------------------------------------
public Boolean SetNoTransform { get; set; } = true;
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets a value indicating whether to set a weak ETag header. </summary>
///-------------------------------------------------------------------------------------------------
public Boolean SetWeakETag { get; set; } = true;
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets the HTML comment marker that indicates the start of page content. </summary>
///-------------------------------------------------------------------------------------------------
public String ContentStartMarker { get; set; } = "<!-- CONTENT_START -->";
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets the HTML comment marker that indicates the end of page content. </summary>
///-------------------------------------------------------------------------------------------------
public String ContentEndMarker { get; set; } = "<!-- CONTENT_END -->";
///-------------------------------------------------------------------------------------------------
/// <summary> Gets or sets the URL path prefixes that should be skipped by the middleware. </summary>
///-------------------------------------------------------------------------------------------------
public List<String> SkipPathPrefixes { get; set; } =
[
"/api/",
"/auth/",
"/cart/",
"/status",
"/error",
"/chathub",
"/assets/"
];
}
Noch eine kurze Erklärung zu zwei enthaltenen Besonderheiten.
Quality-Wert im Accept-Header
Der Accept-HTTP-Header kann optional mehrere gewünschte Formate und jeweils einen "Quality"-Wert enthalten:
Accept: text/html, application/json;q=0.9, text/plain;q=0.5, application/xml;q=0
Dieser gibt an, "wie stark" ein bestimmtes Format gewünscht wird. Der obige Code liefert Markdown aus, wenn der Q-Wert für "text/markdown" größer Null ist.
ETag-Header
Der ETag (Entity Tag) Antwort-Header ist eine ID für eine spezifische Version einer Ressource, z.B. einer HTML-Seite oder – wie in unserem Fall – eines Ergebnisses im Markdown-Format. Dies erlaubt z.B. das Caching einer Ressource. Der Client, der die Ressource gecacht hat, würde das original erhaltene ETag zurück an den Server senden. Wenn sich nichts geändert hat, antwortet dieser mit HTTP-Status 304 (Not Modified) – andernfalls mit der geänderten Ressource und einem neuen ETag-Wert. Dies vermeidet die erneute Übertragung von ungeänderten Inhalten. Ein weiterer Anwendungsfall ist die Handhabung von kollidierenden Edit-Vorgängen, mehr dazu in der Dokumentation. Im obigen Code wird ein ETag ausgeliefert, um z.B. Caching zu ermöglichen.
Deployment Hinweis: Reverse-Proxies/CDNs können je nach Konfiguration einen eigenen Cache-Key nutzen, d.h. es ist sicherzustellen, dass Accept HTML bzw. Markdown + ETag tatsächlich in den Cache-Key einfließen. Sonst drohen ggf. „Cross-Content“-Cache-Treffer (Markdown wird an Browser ausgeliefert oder umgekehrt).
Vary-Header
Mit dem Vary Response Header wird dem Browser (oder dem Agenten) mitgeteilt, welcher Request Header das Format der Antwort beeinflusst. Dies vermittelt der rufenden Instanz das Wissen, dass unterschiedliche Werte bei diesem Request-Header das Ergebnis des Aufrufs beeinflussen.
Vary: Accept
Dies teilt dem Browser also mit, dass das ausgelieferte Ergebnis vom Wert des Accept Request Headers abhängt.
Anwendungsunabhängige Implementierung
Dies ist vor allem für Hosting-Szenarios interessant, in denen ein Reverse Proxy (Nginx, HAProxy, Traefik, YARP, ..) oder Edge Proxy (Cloudflare, AWS CloudFront, Fastly, Akamai, Vercel's Edge Network) vor die eigentliche Webseite "geschaltet" ist. Die Idee ist hier, den "Accept: text/markdown"-Header zu erkennen und entweder
- auf einen Dienst zu leiten, der "on the fly" das HTML-Markup der betreffenden Website abruft und nach Markdown konvertiert, oder
- einen Cache wie Redis zu benutzen, der mit den entsprechenden Markdown-Inhalten "vorbefüllt" wurde.
Beide Ansätze lassen sich natürlich auch kombinieren, indem generiertes Markdown im Cache abgelegt wird.
Vorteile
- Einheitliche Implementierung bei vielen heterogenen Anwendungen
- Eine Lösung für mehrere Webseiten und auch Hosting-Umgebungen
- Anwendungscode muss nicht angepasst werden
Nachteile
- Qualität der HTML-zu-Markdown-Konvertierung ist sehr stark davon abhängig, welche Komplexität der Ursprungs-HTML-Code aufweist (Header, Footer, Sidebars, Ads etc.)
- Ein extra Netzwerk-"Hop", da die Konvertierungsanwendung "zwischen" Proxy und Hauptanwendung geschaltet wird
- Wenn Inhalte pre-generiert werden, dann erfordert dies eine entsprechende Generierungs-Pipeline oder einen Hintergrundjob. Zusätzlich sind Änderungshäufigkeit bzw. Invalidierung der generierten Inhalte zu beachten.
Realisierung
Eine Umsetzung dieses Wegs hängt primär von folgenden Faktoren ab:
- Notwendiger Durchsatz: Ideen wären z.B. eine Node.js-Express-Lösung mit Turndown-Bibliothek oder eine schnelle Go-Implementierung
- Caching-Support
- Qualität der Boilerplate-Entfernung: Ggf. eine Bibliothek wie @mozilla/readability + jsdom einsetzen
Beispiel Nginx-Konfiguration
Eine Nginx-Konfiguration zur Einbindung eines Markdown-Konvertierungsservices könnte wie folgt aussehen.
Hinweis: Der hier referenzierte md_renderer-Upstream ist ein Platzhalter – der eigentliche Konvertierungsservice (z.B. auf Basis von Turndown oder einer vergleichbaren Bibliothek) muss separat implementiert und bereitgestellt werden.
# /etc/nginx/conf.d/site.conf
# Decide upstream based on Accept header (safe to use 'map').
map $http_accept $wants_markdown {
default 0;
~*text/markdown 1;
~*text/x-markdown 1;
}
# Pick the upstream name based on $wants_markdown.
map $wants_markdown $backend_upstream {
0 "html_origin";
1 "md_renderer";
}
upstream html_origin {
# Your normal site/app (example)
server 10.0.10.25:8080;
keepalive 64;
}
upstream md_renderer {
# Your renderer service (example)
server 10.0.20.15:3000;
keepalive 32;
}
server {
listen 80;
server_name www.example.com;
# Optional: if you also terminate TLS, add the 443 server block and redirect HTTP->HTTPS.
location / {
# Route to either html_origin or md_renderer based on Accept.
proxy_pass http://$backend_upstream;
# Forward standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Tell renderer what was originally requested (path + query)
# For HTML origin this is harmless.
proxy_set_header X-Original-URI $request_uri;
# Important for caching correctness (if you enable proxy_cache later):
# The response varies by Accept.
# If you have any intermediate caches, ensure they respect Vary: Accept.
# (NGINX will forward the header; the renderer/app should set it.)
}
}
Mit SSL-Support zusätzlich:
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem;
location / {
proxy_pass http://$backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Original-URI $request_uri;
}
}
server {
listen 80;
server_name www.example.com;
return 301 https://$host$request_uri;
}
Checkliste
Folgende Sachverhalte sind bei beiden (obigen) Ansätzen wichtig:
- Korrekte Cache-Handhabung (bei Verwendung): Der Cache sollte konsistent und aktuell sein (siehe auch ETag-Header oben).
- Inhaltsparität: Das Markdown-Format sollte denselben Hauptinhalt wie HTML widerspiegeln, keine „abgespeckte" Version.
- Boilerplate-Kontrolle: Bei der Konvertierung aus HTML sollten Nav/Fußzeilen/Seitenleisten entfernt werden (die Extraktion im Readability-Stil ist dabei hilfreich).
- Sicherheit: Der Markdown-Dienst darf keine beliebigen URLs abrufen (SSRF). Nur eigene Ursprünge sollten erlaubt sein.
- Observability: Mitführen von Metriken wie Markdown-Treffer, Konvertierungszeit, Cache-Trefferquote und Fehler beim Abrufen des Ursprungs.
- Graceful Fallback: Wenn die Konvertierung fehlschlägt, gibt es mehrere Optionen: HTML zurückgeben (nicht ideal), 406 Not Acceptable zurückgeben oder minimal extrahiertes Markdown (Klartext) zurückgeben.
Alternative Ansätze
Google hat vor kurzem mit WebMCP (Web Model Context Protocol) eine neue JavaScript-Schnittstelle vorgestellt, über die KI-Agenten standardisiert mit Websites kommunizieren sollen. Im Unterschied zu Markdown-for-Agents liegt der Fokus hier nicht auf dem Scrapen des Webseiteninhalts, sondern auf der gezielten Interaktion mit der Website, z.B. um Formulare auszufüllen oder Bestellungen zu tätigen. Beide Ansätze ergänzen sich somit: Markdown-for-Agents optimiert das passive Lesen von Inhalten, WebMCP ermöglicht aktive Interaktion. Die Implementierung von WebMCP setzt allerdings mehr Entwicklungsaufwand voraus, da sie das Bedienen neuer JavaScript-APIs erfordert.
Fazit
Das Ausliefern von Markdown-Inhalten für KI-Agenten ist kein Cloudflare-Privileg – mit überschaubarem Aufwand lässt sich dieses Feature in jede bestehende Webinfrastruktur integrieren. Ob anwendungsspezifisch per Middleware oder proxy-basiert über Nginx und Co.: Entscheidend ist, dass der eigene Content für die nächste Generation von "Lesern" optimiert wird. Wer heute seine Webseiten KI-freundlich gestaltet, sichert sich die Reichweite von morgen – denn der Accept-Header der Zukunft lautet wohl immer öfter text/markdown.
Über uns
Ein erfahrenes Entwicklerteam, das mit Leib und Seele Software erstellt.

Letzte Blogeinträge
Bereitstellung und Verwendung von MCP Servern in Docker
C#/.NET MCP Server Demo
Nützliche Verweise
Kontaktdaten
Brunnstr. 25,
Regensburg
+49 (941) 94592-0
+49 (941) 94592-22