Content Negotiation für KI-Agenten
Was ist Content Negotiation für Agenten?
Content Negotiation bedeutet, unter derselben URL unterschiedliche Antworten auszuliefern — je nachdem, was der Client anfragt. Für KI-Agenten heißt das: Markdown oder Plain Text zurückgeben — das Format, das LLMs ohnehin gut parsen — wenn der Request von einem Agenten kommt, während Browser weiterhin HTML bekommen. Gleiche URL, unterschiedliche Repräsentation, gemäß RFC 9110 §12.5.
Warum das wichtig ist
LLM-gesteuerte Agenten erhalten typischerweise nur den Response-Body — keine HTTP-Header, Statuscodes oder Redirect-Ketten. Wer HTML ausliefert, zwingt den Agenten, DOM-Struktur zu parsen, Layout zu entfernen und Navigation zu verwerfen — Tokens werden verschwendet, bevor das Modell irgendetwas Nützliches sieht. Eine saubere Text-Antwort gibt dem Modell direkt den eigentlichen Inhalt.
Die Falle mit der Accept-Präferenzreihenfolge
Der häufigste Content-Negotiation-Bug ist, den Accept-Header wie einen einfachen Substring-Check zu behandeln. Nimm das WebFetch-Tool von Claude Code — es sendet:
Accept: text/markdown, text/html, */*
Damit sagt der Client, in Präferenzreihenfolge: „Am liebsten Markdown, wenn du es hast, sonst HTML, sonst irgendetwas." Ein naiver Check wie if (accept.includes('text/html')) findet text/html im String und liefert HTML aus — und ignoriert, dass text/markdown zuerst gelistet war.
Gemäß RFC 9110 §12.5.1 drückt bei fehlenden q-Werten die Reihenfolge der Medientypen die Präferenz aus. Eine korrekte Implementierung parst die Accept-Liste, wendet q-Werte an und wählt den am weitesten links stehenden Typ, den der Server ausliefern kann.
Was AgentGrade prüft
Agent-UA bekommt Nicht-HTML — Wir senden User-Agent: claude-code/1.0.0 mit Accept: text/markdown, text/html, */* an deine Homepage. Der Check besteht, wenn du text/markdown, text/plain oder application/json mit einem Body ≥20 Bytes auslieferst. Websites, die Accept per Substring matchen und HTML ausliefern, fallen hier durch.
Accept: JSON liefert JSON — Wir senden Accept: application/json und prüfen auf gültiges JSON.
Accept: text liefert Text — Wir senden Accept: text/plain und prüfen auf Plain Text oder Markdown.
Accept: markdown liefert Markdown — Wir senden Accept: text/markdown und prüfen auf Markdown oder Plain Text.
Vary: Accept gesetzt — Wenn du verhandelst, muss die Antwort Vary: Accept enthalten, damit Shared Caches ihre Einträge mit dem korrekten Cache-Key ablegen.
So implementierst du es korrekt
Nutze einen richtigen Accept-Negotiator statt Substring-Matching:
// Express — req.accepts uses the negotiator package under the hood
app.get('/', async (req, res) => {
res.vary('Accept');
const best = req.accepts(['text/html', 'text/markdown', 'text/plain', 'application/json']);
if (best === 'text/markdown' || best === 'text/plain') {
return res.type(best).send(await buildLlmsTxt());
}
if (best === 'application/json') {
return res.json({ name: 'Your Service', api: '/openapi.json' });
}
res.sendFile('index.html');
});
Andere Ökosysteme:
- Node.js (ohne Framework): npm-Paket
negotiator - Python:
werkzeug.wrappers.AcceptMixinoderrequest.accept_mimetypes.best_match - Go:
github.com/markusthoemmes/goautoneg - Ruby on Rails:
respond_to do |format|-Blöcke übernehmen die Antwortgenerierung, aber Rails behandelt*/*im Accept-Header als Freibrief für HTML —Accept: text/markdown, */*liefert HTML zurück, obwohl Markdown bevorzugt wird. Behebe das, indem durequest.formatexplizit in einerbefore_actionanhand des ersten Nicht-Wildcard-Accept-Typs setzt, bevorrespond_toläuft. Das bloße Umsortieren derformat.X-Blöcke überschreibt den*/*-Fallback nicht. - Cloudflare Workers:
request.headers.get('Accept')manuell parsen oder das npm-Paketacceptnutzen
Inline vs. Redirect — nimm Inline
Es gibt zwei Wege, agentenfreundliche Inhalte auszuliefern. Inline ist besser:
Inline (empfohlen): Dieselbe URL liefert je nach Accept unterschiedliche Bodies aus.
GET / → 200 OK
Content-Type: text/html (browser) | text/markdown (agent)
Redirect (Legacy): Agenten werden auf /llms.txt geschickt.
GET / → 302 Found, Location: /llms.txt
GET /llms.txt → 200 OK
Inline gewinnt, weil: (1) ein Fetch statt zwei — die halbe Latenz; (2) die URL, die der Agent dem Nutzer meldet, ist die URL, nach der gefragt wurde, nicht ein Redirect-Ziel; (3) das Caching ist mit Vary: Accept sauberer. Die /llms.txt-Route existiert weiterhin für Tools, die sie direkt abrufen — beide Routen rufen dieselbe Content-Funktion auf, sodass es eine einzige Quelle der Wahrheit gibt.
Vary: Accept ist tragend
Immer wenn dieselbe URL je nach Accept unterschiedliche Bodies zurückgibt, setze Vary: Accept. Das teilt Shared Caches (CDNs, Proxys, Browsern) mit, dass der Cache-Key den Wert des Accept-Headers enthalten muss.
Ohne diesen Header könnte ein CDN die Markdown-Antwort eines Agenten-Fetches cachen und sie einem Browser-Besuch ausliefern — oder umgekehrt. Der Vary-Header ist das Einzige, was verhindert, dass Cache-Einträge als austauschbar behandelt werden, wenn die Bodies es nicht sind.
Bekannte User-Agents von KI-Agenten
| Agent | User-Agent | Zweck |
|---|---|---|
| ClaudeBot | Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com) | Anthropic-Trainings-Crawler |
| Claude-User | Mozilla/5.0 ... (compatible; Claude-User/1.0; +Claude-User@anthropic.com) | claude.ai web_fetch, Claude-API-web_search-Seitenabrufe |
| Claude-SearchBot | (String nicht veröffentlicht) | Anthropic-Suchindex-Crawler |
| claude-code | claude-code/<version> | WebFetch-Tool der Claude Code CLI |
| ChatGPT-User | Mozilla/5.0 ... (compatible; ChatGPT-User/1.0; +https://openai.com/bot) | Nutzerinitiiertes ChatGPT-Browsen |
| OAI-SearchBot | Mozilla/5.0 ... (compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot) | OpenAI-Suchindex |
| OAI-AdsBot | Mozilla/5.0 ... (compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot) | OpenAI-Ads-Crawler |
| GPTBot | Mozilla/5.0 ... (compatible; GPTBot/1.3; +https://openai.com/gptbot) | OpenAI-Trainings-Crawler |
| PerplexityBot | Mozilla/5.0 ... (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot) | Perplexity-Suchergebnis-Crawler |
| Perplexity-User | Mozilla/5.0 ... (compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user) | Nutzerinitiierte Perplexity-Fetches |
| Google-Extended | (nutzt Googlebot-UA) | Google-Gemini-Training, gesteuert über robots.txt |
Web Bot Auth — das nächste Signal
Eine wachsende Zahl von Agenten (ChatGPT Agent heute bestätigt; Anthropic, Perplexity, Google erwartet) signiert ihre Requests kryptografisch gemäß RFC 9421 HTTP Message Signatures. Das Signal ist der Request-Header Signature-Agent:
Signature-Agent: "https://chatgpt.com"
Signature-Input: sig=("@authority" "signature-agent"); keyid="..."; tag="web-bot-auth"
Signature: sig=...
Wenn du Signature-Agent in einem eingehenden Request siehst, behandle den Client als bekannten Agenten — auch wenn der UA nach Browser aussieht. Für die vollständige Verifizierung rufst du das JWKS beim in Signature-Agent genannten Host ab (/.well-known/http-message-signatures-directory) und verifizierst die Signatur mit dem npm-Paket web-bot-auth. Für Content-Negotiation-Zwecke reicht das bloße Vorhandensein des Headers als weiches Signal.
Mehr erfahren
- RFC 9110 §12.5 — Content Negotiation
- llms.txt specification
- Anthropic crawler docs
- OpenAI bot docs
- Perplexity crawler docs
- Cloudflare Web Bot Auth
Bevorzugter Typ vs. Nicht-HTML — die nächste Messlatte
„Agent-UA bekommt Nicht-HTML" ist ein Basis-Check: Hat der Server irgendetwas anderes als HTML ausgeliefert? „Liefert bevorzugten Content-Type" ist die strenge Variante: Entsprach der Content-Type der Antwort dem führenden Typ, den der Client signalisiert hat?
Beispiel: Der Client sendet Accept: text/markdown, text/html, */*:
- Server liefert
Content-Type: text/markdown→ besteht beide Checks - Server liefert
Content-Type: text/plain→ besteht den Basis-Check, fällt beim strengen durch - Server liefert
Content-Type: text/html→ fällt bei beiden durch
Der Scanner führt vier Proben aus: Markdown führend (das Claude-Code-/Cursor-Muster), HTML führend mit Markdown an zweiter Stelle (das Browser-/ChatGPT-Agent-Muster — erwischt Websites, die die Client-Reihenfolge ignorieren und stattdessen eine serverseitige Präferenz nutzen), explizite q-Werte mit HTML vor Markdown (erwischt Websites, die q-Werte komplett ignorieren) und JSON führend (das Muster für programmatische Discovery). Alle vier müssen bestehen. Heute ist das Content-Type-Label für LLM-basierte Agenten meist dekorativ — sie parsen die Body-Bytes unabhängig vom MIME-Type. Aber browserbasierte KI-Extensions und aufkommende MCP-Tools verzweigen auf den Content-Type, und die Lücke wird größer, je reifer das Ökosystem wird.
Der Fix ist eine Ein-Zeilen-Änderung in deinem Handler: Setze den Response-Content-Type aus dem verhandelten Typ, nicht aus einem hartkodierten Wert. Wenn dein Code für Accept: text/plain und Accept: text/markdown bereits jeweils text/plain zurückgibt, verzweige auf den verhandelten Typ und beschrifte entsprechend.
Dieser Check ist Pflicht — wer durchfällt, verliert Punkte in der Gruppe „Content Negotiation".
Deinen Bug diagnostizieren — q-Werte und die drei Muster
Wie q-Werte funktionieren
Wenn ein Client mehrere Typen im Accept-Header sendet, kann er ihnen q-Werte (Quality-Faktoren) zwischen 0.0 und 1.0 anhängen, um relative Präferenz auszudrücken:
Accept: text/markdown;q=1.0, text/html;q=0.5, */*;q=0.1
Bedeutung: „Ich will unbedingt Markdown. HTML nehme ich als Backup. Alles andere ist die letzte Option."
Fehlt der q-Wert, ist der Default 1.0. Accept: text/markdown, text/html, */* heißt also: Alle drei sind gleich bevorzugt — und die Reihenfolge im Header entscheidet bei Gleichstand. Ein korrekter Server wählt Markdown.
Ein richtiger Accept-Negotiator (Express req.accepts(), das npm-Paket negotiator, Python werkzeug, Go goautoneg) erledigt all das automatisch: q-Werte parsen, bei Gleichstand die Reihenfolge respektieren, den besten Typ wählen, den der Server ausliefern kann.
Die drei Bug-Muster, die wir in freier Wildbahn sehen
Wenn deine Website am Check „Agent-UA bekommt Nicht-HTML" scheitert, ist die Ursache fast immer eines dieser Muster:
Muster 1: Substring-Matching. Code, der prüft, ob der Accept-Header einen Typ enthält, in fester if-else-Reihenfolge. Beispiel:
// WRONG — order of checks, not order in Accept, wins
if (accept.includes('text/html')) return html;
else if (accept.includes('text/markdown')) return markdown;
Client sendet Accept: text/markdown, text/html → Server liefert HTML, weil text/html im String vorkommt. Die Präferenzreihenfolge des Clients wird komplett ignoriert.
Muster 2: Framework-Default, der bei */* HTML ausliefert. Manche Frameworks behandeln */* im Accept-Header als Freibrief, auf HTML zurückzufallen — selbst wenn explizite Nicht-HTML-Typen früher gelistet sind. Rails 8s respond_to ist ein prominentes Beispiel:
Accept: text/markdown, */* → Rails returns HTML (markdown ignored)
Accept: text/markdown, text/html → Rails returns markdown (no */*, honors order)
Muster 3: Serverinterne Präferenzreihenfolge + ignorierte q-Werte. Der Server hat seine eigene Prioritätenliste (oft irgendwo hartkodiert) und wählt den Typ aus dem Accept-Header, der auf der Server-Liste am höchsten steht — nicht auf der Client-Liste. q-Werte werden gar nicht geparst:
Accept: text/plain;q=0.9, text/html;q=0.5 → returns HTML
(server prefers html despite q-values
explicitly favoring plain)
Das eindeutige Indiz für Muster 3 sind ignorierte q-Werte. Wenn dieselbe Website für die Zeile oben HTML liefert und für Accept: text/plain, text/markdown Markdown (Markdown gewann, obwohl plain zuerst gelistet war), ist es Muster 3.
Ein schneller Diagnosetest
Führe diese fünf curl-Befehle gegen deine Homepage aus. Das Muster in den Antworten verrät dir, welchen Bug du hast:
curl -sI -H "Accept: text/markdown" YOUR_SITE/
curl -sI -H "Accept: text/markdown, text/html, */*" YOUR_SITE/
curl -sI -H "Accept: text/plain, text/markdown" YOUR_SITE/
curl -sI -H "Accept: text/markdown, */*" YOUR_SITE/
curl -sI -H "Accept: text/plain;q=0.9, text/html;q=0.5" YOUR_SITE/
- Wenn nur der erste Markdown liefert: Muster 1 (Substring-Matching).
- Wenn die ersten drei Markdown liefern, aber der vierte HTML: Muster 2 (
*/*-Fallback). - Wenn der fünfte HTML liefert und der dritte Markdown (oder umgekehrt zu dem, was du auszuliefern glaubst): Muster 3 (Server-Präferenz + ignorierte q-Werte).
Das Fix-Rezept ist in allen drei Fällen dasselbe: Ersetze deine Ad-hoc-Auswahllogik durch einen richtigen Accept-Negotiator aus der Liste oben.
Inline vs. 302-Redirect — was tun
Zwei Muster, um agentenfreundliche Inhalte auf deiner Homepage auszuliefern:
Inline — dieselbe URL liefert je nach Accept-Header unterschiedliche Bodies zurück.
GET / + Accept: text/html → 200 + HTML
GET / + Accept: text/markdown → 200 + markdown
Redirect — der Server schickt Agenten-Requests an eine separate kanonische URL.
GET / + Accept: text/markdown → 302, Location: /llms.txt
GET /llms.txt → 200 + markdown
Nimm Inline. Das ist die dokumentierte Best Practice in RFC 9110 §12.2, die die Nachteile redirect-basierter (reaktiver) Negotiation explizit auflistet: „suffers from transmitting a list of alternatives... and needing a second request to obtain an alternate representation" und „does not define a mechanism for supporting automatic selection."
Jede große Content-Negotiation-bewusste Website, die wir getestet haben, nutzt Inline:
- GitHub API — dieselbe URL variiert nach Accept (
application/vnd.github+jsonvs.application/vnd.github.html+json), kein Redirect - Stripe docs —
docs.stripe.com/apiliefert HTML oder Markdown von derselben URL mitVary: Accept - Cloudflare developer docs — die Edge konvertiert inline, dieselbe URL
- Vercel, Mintlify, Sanity — alle empfehlen in ihren öffentlichen Leitfäden Inline für agentenfreundliche Seiten
Warum Inline konkret gewinnt
- Halbe Latenz. Ein HTTP-Fetch statt zwei. HTTP/2- und HTTP/3-Multiplexing eliminieren die Redirect-Kosten nicht — der Client muss den 302 trotzdem empfangen,
Locationparsen und einen neuen Request senden. - URL-Treue. Die URL, die der Agent dem Nutzer meldet, ist die URL, nach der der Nutzer tatsächlich gefragt hat. Mit 302 landet der Agent bei
/llms.txt— einer anderen URL als der Homepage. - Saubereres Caching. Inline mit
Vary: Acceptlässt Caches beide Repräsentationen unter einem URL-Key speichern. Mit 302 müssen Caches zwei URLs verwalten und deren Kohärenz sicherstellen. - Keine magische Nur-Agenten-URL. Inline hält den URL-Raum einheitlich — Menschen und Agenten treffen dieselbe URL; der Server entscheidet anhand von Accept, was er ausliefert.
Was AgentGrade prüft
Der Check Inline content negotiation sendet einen agentenförmigen Request (claude-code/1.0.0-UA mit Accept: text/markdown, text/html, */*) und verifiziert, dass die Antwort nicht bei einer anderen URL landet als ein Browser-Request. Konkret: Wenn der Agenten-Fetch auf einen Pfad umgeleitet wurde, auf den der Browser-Fetch nicht umgeleitet wurde, fällt der Check durch.
Universelle Redirects, die alle betreffen (HTTPS-Upgrade, Trailing-Slash-Normalisierung), werden nicht bestraft — nur agentenspezifische Redirects auf eine separate URL.
So behebst du es
Ersetze deine 302-Logik durch Inline-Negotiation. Express-Beispiel:
app.get('/', async (req, res) => {
res.vary('Accept');
const best = req.accepts(['text/html', 'text/markdown', 'text/plain']);
if (best === 'text/markdown') {
return res.type('text/markdown').send(await buildLlmsTxt());
}
// Honor browser preference
res.sendFile('index.html');
});
Die /llms.txt-Route kann als separate URL weiter existieren — beide Routen rufen dieselbe Content-Funktion auf. Websites, die /llms.txt direkt abrufen, funktionieren weiterhin; Websites, die / mit einem Agenten-Accept treffen, funktionieren auch — in einem einzigen Request.
Dieser Check ist heute noch „emerging" (optional) — er bestraft Websites mit 302 noch nicht. Er wird zur Pflicht, sobald die Branchenakzeptanz von Inline breit genug ist, dass die wenigen verbliebenen 302-basierten Websites klar die Ausreißer sind.