Negociación de Contenido para Agentes de IA
¿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.
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, 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:
// 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
negotiatoren npm - Python:
werkzeug.wrappers.AcceptMixinorequest.accept_mimetypes.best_match - Go:
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: establecerequest.formatexplícitamente en unbefore_actionbasado en el primer tipo Accept no comodín, antes de que se ejecuterespond_to. Reordenar los bloquesformat.Xpor sí solo no anula el fallback de*/*. - Cloudflare Workers: parsea
request.headers.get('Accept')manualmente o usa el paqueteacceptde 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. 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 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
- Especificación de llms.txt
- Documentación de crawlers de Anthropic
- Documentación de bots de OpenAI
- Documentación de crawlers de Perplexity
- Web Bot Auth de Cloudflare
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:
// 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:
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, 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/apidevuelve HTML o markdown desde la misma URL conVary: 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
- 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.
- 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. - Caching más limpio. Inline con
Vary: Acceptpermite que las cachés almacenen ambas representaciones bajo una clave de URL. - 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:
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.