## 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](https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation).

## 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](https://www.rfc-editor.org/rfc/rfc9110#name-accept) 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:

```javascript
// 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`](https://www.npmjs.com/package/negotiator)
- **Python:** `werkzeug.wrappers.AcceptMixin` oder `request.accept_mimetypes.best_match`
- **Go:** [`github.com/markusthoemmes/goautoneg`](https://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 du `request.format` explizit in einer `before_action` anhand des ersten Nicht-Wildcard-Accept-Typs setzt, bevor `respond_to` läuft. Das bloße Umsortieren der `format.X`-Blöcke überschreibt den `*/*`-Fallback nicht.
- **Cloudflare Workers:** `request.headers.get('Accept')` manuell parsen oder das npm-Paket `accept` nutzen

## 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](https://www.rfc-editor.org/rfc/rfc9421). 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`](https://www.npmjs.com/package/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](https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation)
- [llms.txt specification](https://llmstxt.org/)
- [Anthropic crawler docs](https://support.claude.com/en/articles/8896518)
- [OpenAI bot docs](https://developers.openai.com/api/docs/bots)
- [Perplexity crawler docs](https://docs.perplexity.ai/docs/resources/perplexity-crawlers)
- [Cloudflare Web Bot Auth](https://blog.cloudflare.com/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:

```javascript
// 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:

```bash
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](https://www.rfc-editor.org/rfc/rfc9110#name-reactive-negotiation), 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+json` vs. `application/vnd.github.html+json`), kein Redirect
- **Stripe docs** — `docs.stripe.com/api` liefert HTML oder Markdown von derselben URL mit `Vary: 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

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:

```javascript
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.
