AgentGrade
EnglishEspañolDeutsch日本語中文
← Wissensdatenbank

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:

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

AgentUser-AgentZweck
ClaudeBotMozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)Anthropic-Trainings-Crawler
Claude-UserMozilla/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-codeclaude-code/<version>WebFetch-Tool der Claude Code CLI
ChatGPT-UserMozilla/5.0 ... (compatible; ChatGPT-User/1.0; +https://openai.com/bot)Nutzerinitiiertes ChatGPT-Browsen
OAI-SearchBotMozilla/5.0 ... (compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot)OpenAI-Suchindex
OAI-AdsBotMozilla/5.0 ... (compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot)OpenAI-Ads-Crawler
GPTBotMozilla/5.0 ... (compatible; GPTBot/1.3; +https://openai.com/gptbot)OpenAI-Trainings-Crawler
PerplexityBotMozilla/5.0 ... (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)Perplexity-Suchergebnis-Crawler
Perplexity-UserMozilla/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

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, */*:

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/

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:

Warum Inline konkret gewinnt

  1. 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, Location parsen und einen neuen Request senden.
  2. 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.
  3. Saubereres Caching. Inline mit Vary: Accept lässt Caches beide Repräsentationen unter einem URL-Key speichern. Mit 302 müssen Caches zwei URLs verwalten und deren Kohärenz sicherstellen.
  4. 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.