## ¿Qué es la negociación de contenido para agentes?

La negociación de contenido significa servir distintas respuestas desde la misma URL según lo que pida el cliente. Para los agentes de IA, esto se traduce en devolver markdown o texto plano —el formato que los LLM ya procesan bien— cuando la solicitud proviene de un agente, mientras se sigue sirviendo HTML a los navegadores. Misma URL, distinta representación, según [RFC 9110 §12.5](https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation).

## Por qué importa

Los agentes basados en LLM normalmente reciben solo el cuerpo de la respuesta, no los encabezados HTTP, códigos de estado ni cadenas de redirección. Servir HTML obliga al agente a parsear la estructura del DOM, descartar el layout y la navegación — desperdiciando tokens antes de que el modelo vea algo útil. Una respuesta de texto limpia le da al modelo el contenido directamente.

## La trampa del orden de preferencia de Accept

El error más común en negociación de contenido es tratar el encabezado Accept como una simple búsqueda de subcadena. Por ejemplo, la herramienta WebFetch de Claude Code envía:

```
Accept: text/markdown, text/html, */*
```

Esto es el cliente diciendo, en orden de preferencia: "Prefiero markdown si lo tienes, si no HTML, si no cualquier cosa." Una verificación ingenua como `if (accept.includes('text/html'))` ve `text/html` en la cadena y sirve HTML — ignorando que `text/markdown` estaba listado primero.

Según [RFC 9110 §12.5.1](https://www.rfc-editor.org/rfc/rfc9110#name-accept), cuando no se especifican valores q, el **orden de los tipos de medios expresa preferencia**. Una implementación correcta parsea la lista Accept, aplica los valores q y elige el tipo más a la izquierda que el servidor puede servir.

## Qué verifica AgentGrade

**El UA de agente recibe contenido no-HTML** — Enviamos `User-Agent: claude-code/1.0.0` con `Accept: text/markdown, text/html, */*` a tu homepage. La verificación pasa si sirves `text/markdown`, `text/plain` o `application/json` con cuerpo ≥20 bytes. Los sitios que hacen búsqueda de subcadena en Accept y sirven HTML fallan aquí.

**Accept: JSON devuelve JSON** — Enviamos `Accept: application/json` y verificamos JSON válido.

**Accept: text devuelve texto** — Enviamos `Accept: text/plain` y verificamos texto plano o markdown.

**Accept: markdown devuelve markdown** — Enviamos `Accept: text/markdown` y verificamos markdown o texto plano.

**Vary: Accept establecido** — Cuando negocias, la respuesta debe incluir `Vary: Accept` para que las cachés compartidas indexen las entradas correctamente.

## Cómo implementarlo correctamente

Usa un negociador Accept apropiado en lugar de búsqueda de subcadena:

```javascript
// Express — req.accepts usa el paquete negotiator internamente
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: 'Tu Servicio', api: '/openapi.json' });
  }
  res.sendFile('index.html');
});
```

Otros ecosistemas:
- **Node.js (sin framework):** paquete [`negotiator`](https://www.npmjs.com/package/negotiator) en npm
- **Python:** `werkzeug.wrappers.AcceptMixin` o `request.accept_mimetypes.best_match`
- **Go:** [`github.com/markusthoemmes/goautoneg`](https://github.com/markusthoemmes/goautoneg)
- **Ruby on Rails:** los bloques `respond_to do |format|` manejan la generación de respuestas, pero Rails trata `*/*` en Accept como una licencia para servir HTML — `Accept: text/markdown, */*` devuelve HTML aunque markdown sea preferido. Solución: establece `request.format` explícitamente en un `before_action` basado en el primer tipo Accept no comodín, antes de que se ejecute `respond_to`. Reordenar los bloques `format.X` por sí solo no anula el fallback de `*/*`.
- **Cloudflare Workers:** parsea `request.headers.get('Accept')` manualmente o usa el paquete `accept` de npm

## En línea vs redirección — elige en línea

Hay dos formas de servir contenido amigable para agentes. En línea es mejor:

**En línea (recomendado):** La misma URL sirve distintos cuerpos según Accept.

```
GET / → 200 OK
  Content-Type: text/html (navegador) | text/markdown (agente)
```

**Redirección (legado):** Envía a los agentes a `/llms.txt`.

```
GET / → 302 Found, Location: /llms.txt
GET /llms.txt → 200 OK
```

En línea gana porque: (1) una solicitud en lugar de dos — la mitad de la latencia; (2) la URL que el agente reporta al usuario es la URL sobre la que se le preguntó, no el destino de la redirección; (3) el caching es más limpio con `Vary: Accept`. La ruta `/llms.txt` sigue existiendo para herramientas que la solicitan directamente — ambas rutas llaman a la misma función de contenido.

## Vary: Accept es crítico

Siempre que la misma URL devuelva distintos cuerpos según Accept, establece `Vary: Accept`. Esto le dice a las cachés compartidas (CDNs, proxies, navegadores) que la clave de caché debe incluir el valor del encabezado Accept.

Sin él, un CDN podría cachear la respuesta markdown de una solicitud de agente y servirla a una visita de navegador — o al revés. El encabezado Vary es lo único que evita que las entradas de caché sean intercambiables cuando los cuerpos no lo son.

## User-Agents conocidos de agentes de IA

| Agente | User-Agent | Propósito |
|---|---|---|
| ClaudeBot | `Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)` | Crawler de entrenamiento de Anthropic |
| Claude-User | `Mozilla/5.0 ... (compatible; Claude-User/1.0; +Claude-User@anthropic.com)` | claude.ai web_fetch, lecturas de página de Claude API web_search |
| Claude-SearchBot | (cadena no publicada) | Crawler del índice de búsqueda de Anthropic |
| claude-code | `claude-code/<version>` | Herramienta WebFetch de Claude Code CLI |
| ChatGPT-User | `Mozilla/5.0 ... (compatible; ChatGPT-User/1.0; +https://openai.com/bot)` | Navegación ChatGPT iniciada por usuario |
| OAI-SearchBot | `Mozilla/5.0 ... (compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot)` | Índice de búsqueda de OpenAI |
| OAI-AdsBot | `Mozilla/5.0 ... (compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot)` | Crawler de anuncios de OpenAI |
| GPTBot | `Mozilla/5.0 ... (compatible; GPTBot/1.3; +https://openai.com/gptbot)` | Crawler de entrenamiento de OpenAI |
| PerplexityBot | `Mozilla/5.0 ... (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)` | Crawler de resultados de búsqueda de Perplexity |
| Perplexity-User | `Mozilla/5.0 ... (compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)` | Solicitudes de Perplexity iniciadas por usuario |
| Google-Extended | (usa UA de Googlebot) | Entrenamiento de Google Gemini, controlado vía robots.txt |

## Web Bot Auth — la próxima señal

Una lista creciente de agentes (ChatGPT Agent confirmado hoy; Anthropic, Perplexity, Google esperados) firma criptográficamente sus solicitudes según [RFC 9421 HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421). La señal es el encabezado `Signature-Agent`:

```
Signature-Agent: "https://chatgpt.com"
Signature-Input: sig=("@authority" "signature-agent"); keyid="..."; tag="web-bot-auth"
Signature: sig=...
```

Si ves `Signature-Agent` en una solicitud entrante, trata al cliente como un agente conocido incluso si el UA parece un navegador. Para verificación completa, descarga el JWKS desde el host nombrado en `Signature-Agent` (`/.well-known/http-message-signatures-directory`) y verifica la firma con el paquete [`web-bot-auth`](https://www.npmjs.com/package/web-bot-auth) de npm. Para fines de negociación de contenido, la presencia del encabezado por sí sola es una señal suave suficiente.

## Más información

- [RFC 9110 §12.5 — Negociación de contenido](https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation)
- [Especificación de llms.txt](https://llmstxt.org/)
- [Documentación de crawlers de Anthropic](https://support.claude.com/en/articles/8896518)
- [Documentación de bots de OpenAI](https://developers.openai.com/api/docs/bots)
- [Documentación de crawlers de Perplexity](https://docs.perplexity.ai/docs/resources/perplexity-crawlers)
- [Web Bot Auth de Cloudflare](https://blog.cloudflare.com/web-bot-auth/)

## Tipo preferido vs no-HTML — el siguiente listón

"El UA de agente recibe contenido no-HTML" es una verificación básica: ¿el servidor sirvió algo distinto de HTML? "Devuelve el Content-Type preferido" es la versión estricta: ¿coincide el Content-Type de la respuesta con el tipo líder señalado por el cliente?

Por ejemplo, cuando el cliente envía `Accept: text/markdown, text/html, */*`:

- El servidor devuelve `Content-Type: text/markdown` → pasa ambas verificaciones
- El servidor devuelve `Content-Type: text/plain` → pasa la básica, falla la estricta
- El servidor devuelve `Content-Type: text/html` → falla ambas

El escáner ejecuta cuatro pruebas: markdown primero (el patrón de Claude Code / Cursor), HTML primero con markdown listado en segundo lugar (el patrón de navegador / ChatGPT Agent — detecta sitios que ignoran el orden del cliente y usan preferencia del lado del servidor en su lugar), valores q explícitos favoreciendo HTML sobre markdown (detecta sitios que ignoran los valores q por completo), y JSON primero (patrón de descubrimiento programático). Las cuatro deben pasar. Hoy la etiqueta Content-Type es mayormente decorativa para los agentes basados en LLM — analizan los bytes del cuerpo independientemente del tipo MIME. Pero las extensiones de IA basadas en navegador y las herramientas MCP emergentes se ramifican según Content-Type, y la brecha se ampliará a medida que madure el ecosistema.

La solución es un cambio de una línea en tu handler: establece el Content-Type de la respuesta desde el tipo negociado, no un valor codificado. Si tu código ya devuelve `text/plain` tanto para `Accept: text/plain` como para `Accept: text/markdown`, ramifica según el tipo negociado y etiqueta apropiadamente.

Esta verificación es requerida — fallarla cuesta puntos en el grupo de Negociación de contenido.

## Diagnóstico de tu bug — valores q y los tres patrones

### Cómo funcionan los valores q

Cuando un cliente envía múltiples tipos en Accept, puede adjuntar **valores q** (factores de calidad) entre 0.0 y 1.0 para expresar preferencia *relativa*:

```
Accept: text/markdown;q=1.0, text/html;q=0.5, */*;q=0.1
```

Significa: "Realmente quiero markdown. Tomaré HTML como respaldo. Cualquier otra cosa es último recurso."

Cuando no se da un valor q, predetermina a 1.0. Entonces `Accept: text/markdown, text/html, */*` significa que los tres son igualmente preferidos — y el **orden en el encabezado** rompe el empate. Un servidor correcto elige markdown.

Un negociador Accept adecuado (Express `req.accepts()`, el paquete `negotiator` de npm, Python `werkzeug`, Go `goautoneg`) maneja todo esto automáticamente: analiza valores q, respeta el orden en empates, elige la mejor coincidencia que el servidor puede servir.

### Los tres patrones de bug que vemos en producción

Si tu sitio falla la verificación "El UA de agente recibe contenido no-HTML", la causa es casi siempre una de estas:

**Patrón 1: Coincidencia de subcadenas.** Código que verifica si el encabezado Accept *contiene* un tipo, en un orden if-else fijo. Ejemplo:

```javascript
// INCORRECTO — el orden de las verificaciones, no el orden en Accept, gana
if (accept.includes('text/html')) return html;
else if (accept.includes('text/markdown')) return markdown;
```

El cliente envía `Accept: text/markdown, text/html` → el servidor devuelve HTML porque `text/html` está en la cadena. El orden de preferencia del cliente se ignora por completo.

**Patrón 2: Valor predeterminado del framework que sirve HTML en `*/*`.** Algunos frameworks tratan `*/*` en Accept como una licencia para recurrir a HTML, incluso cuando se enumeran tipos no-HTML explícitos primero. `respond_to` de Rails 8 es un ejemplo notable:

```
Accept: text/markdown, */*       → Rails devuelve HTML (markdown ignorado)
Accept: text/markdown, text/html → Rails devuelve markdown (sin */*, respeta orden)
```

**Patrón 3: Orden de preferencia interno del servidor + valores q ignorados.** El servidor tiene su propia lista de prioridad (a menudo codificada en algún lugar) y elige el tipo del encabezado Accept que sea más alto *en la lista del servidor* — no en la lista del cliente. Los valores q no se analizan en absoluto:

```
Accept: text/plain;q=0.9, text/html;q=0.5 → devuelve HTML
                                            (el servidor prefiere html a pesar de los
                                             valores q que favorecen explícitamente plain)
```

La prueba inequívoca del patrón 3 es que los valores q se ignoran. Si el mismo sitio devuelve HTML para la fila anterior y markdown para `Accept: text/plain, text/markdown` (markdown ganó a pesar de que plain está listado primero), es patrón 3.

### Una prueba diagnóstica rápida

Ejecuta estos cinco comandos curl contra tu homepage. El patrón en las respuestas te dice qué bug tienes:

```bash
curl -sI -H "Accept: text/markdown" TU_SITIO/
curl -sI -H "Accept: text/markdown, text/html, */*" TU_SITIO/
curl -sI -H "Accept: text/plain, text/markdown" TU_SITIO/
curl -sI -H "Accept: text/markdown, */*" TU_SITIO/
curl -sI -H "Accept: text/plain;q=0.9, text/html;q=0.5" TU_SITIO/
```

- Si solo la primera devuelve markdown: patrón 1 (coincidencia de subcadenas).
- Si las primeras tres devuelven markdown pero la cuarta devuelve HTML: patrón 2 (fallback de `*/*`).
- Si la quinta devuelve HTML y la tercera devuelve markdown (o viceversa con lo que crees que sirves): patrón 3 (preferencia del servidor + valores q ignorados).

La receta de solución es la misma en los tres casos: reemplaza la lógica de selección ad-hoc con un negociador Accept adecuado de la lista anterior.

## Inline vs redirección 302 — qué hacer

Dos patrones para servir contenido amigable para agentes en tu homepage:

**Inline** — la misma URL devuelve diferentes cuerpos según el encabezado Accept.

```
GET /  + Accept: text/html      →  200 + HTML
GET /  + Accept: text/markdown  →  200 + markdown
```

**Redirección** — el servidor envía las solicitudes de agentes a una URL canónica separada.

```
GET /  + Accept: text/markdown  →  302, Location: /llms.txt
GET /llms.txt                   →  200 + markdown
```

**Usa inline.** Es la mejor práctica documentada en [RFC 9110 §12.2](https://www.rfc-editor.org/rfc/rfc9110#name-reactive-negotiation), que enumera explícitamente las desventajas de la negociación basada en redirecciones (reactiva): "sufre de transmitir una lista de alternativas... y necesitar una segunda solicitud" y "no define un mecanismo para soportar selección automática."

Todos los principales sitios con negociación de contenido que probamos usan inline:

- **GitHub API** — la misma URL varía según Accept, sin redirección
- **Stripe docs** — `docs.stripe.com/api` devuelve HTML o markdown desde la misma URL con `Vary: Accept`
- **Cloudflare developer docs** — conversión en el edge, misma URL
- **Vercel**, **Mintlify**, **Sanity** — todos recomiendan inline en sus guías públicas

## Por qué inline gana concretamente

1. **La mitad de la latencia.** Una solicitud HTTP en lugar de dos. La multiplexación de HTTP/2 y HTTP/3 no elimina el costo de la redirección.
2. **Fidelidad de URL.** La URL que el agente reporta al usuario es la URL sobre la que preguntó. Con 302, el agente termina en `/llms.txt` — una URL diferente.
3. **Caching más limpio.** Inline con `Vary: Accept` permite que las cachés almacenen ambas representaciones bajo una clave de URL.
4. **Sin URL mágica solo-para-agentes.** Inline mantiene el espacio de URLs unificado.

## Qué verifica AgentGrade

La verificación `Inline content negotiation` envía una solicitud con forma de agente (`claude-code/1.0.0` UA con `Accept: text/markdown, text/html, */*`) y verifica que la respuesta no termine en una URL diferente a la que terminaría una solicitud de navegador. Las redirecciones universales (HTTPS, normalización de barra) no se penalizan — solo las redirecciones específicas para agentes.

## Cómo solucionarlo

Reemplaza tu lógica 302 con negociación inline. Ejemplo en Express:

```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());
  }
  res.sendFile('index.html');
});
```

La ruta `/llms.txt` aún puede existir como URL separada — ambas rutas llaman a la misma función de contenido.

Esta verificación es emergente (opcional) hoy — todavía no penaliza a los sitios que usan 302. Graduará a requerida una vez que la adopción de inline en la industria sea lo suficientemente amplia.
