AIエージェント向けコンテンツネゴシエーション
エージェント向けコンテンツネゴシエーションとは
コンテンツネゴシエーションとは、クライアントが要求する内容に応じて同じURLから異なるレスポンスを返すことです。AIエージェントの場合、エージェントからのリクエストには Markdown やプレーンテキスト(LLMが既にうまくパースできる形式)を返し、ブラウザには引き続き HTML を返します。同じURL、異なる表現 — RFC 9110 §12.5 に基づきます。
なぜ重要か
LLM駆動のエージェントは通常、HTTPヘッダー、ステータスコード、リダイレクトチェーンではなく、レスポンスボディのみを受け取ります。HTML を提供すると、エージェントは DOM 構造をパースし、レイアウトを取り除き、ナビゲーションを破棄する必要があり — モデルが有用なものを見る前にトークンを浪費します。クリーンなテキストレスポンスはモデルに実際のコンテンツを直接渡します。
Accept 優先順位の落とし穴
最も一般的なコンテンツネゴシエーションのバグは、Accept ヘッダーを単純な部分文字列チェックとして扱うことです。Claude Code の WebFetch ツールは次を送信します:
Accept: text/markdown, text/html, */*
これはクライアントが優先順位順に次のように言っています:「あれば markdown を、なければ HTML を、なければ何でも」。if (accept.includes('text/html')) のような単純なチェックは文字列内の text/html を見て HTML を返します — text/markdown が最初にリストされていることを無視して。
RFC 9110 §12.5.1 によると、q値が指定されていない場合、メディアタイプの順序が優先順位を表します。正しい実装は Accept リストをパースし、q値を適用し、サーバーが提供できる最も左の型を選択します。
AgentGrade が確認すること
エージェント UA が非 HTML を取得 — ホームページに User-Agent: claude-code/1.0.0 と Accept: text/markdown, text/html, */* を送信します。text/markdown、text/plain、または application/json を 20 バイト以上のボディで返せばチェックに合格します。Accept を部分文字列でマッチして HTML を返すサイトは失敗します。
Accept: JSON が JSON を返す — Accept: application/json を送信して有効な JSON が返るか確認。
Accept: text がテキストを返す — Accept: text/plain を送信してプレーンテキストか markdown を確認。
Accept: markdown が markdown を返す — Accept: text/markdown を送信して markdown かプレーンテキストを確認。
Vary: Accept 設定 — ネゴシエーションする場合、共有キャッシュがエントリーを正しくキー付けできるよう、レスポンスに Vary: Accept を含める必要があります。
正しい実装方法
部分文字列マッチングの代わりに、適切な Accept ネゴシエーターを使用します:
// Express — req.accepts は内部で negotiator パッケージを使用
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: 'あなたのサービス', api: '/openapi.json' });
}
res.sendFile('index.html');
});
他のエコシステム:
- Node.js(フレームワーク無し):
negotiatornpmパッケージ - Python:
werkzeug.wrappers.AcceptMixinまたはrequest.accept_mimetypes.best_match - Go:
github.com/markusthoemmes/goautoneg - Ruby on Rails:
respond_to do |format|ブロックはレスポンス生成を処理しますが、Rails は Accept 内の*/*を HTML を提供するライセンスとして扱います —Accept: text/markdown, */*は markdown が優先されていても HTML を返します。修正方法:respond_toが実行される前に、先頭のワイルドカード以外の Accept タイプに基づいてbefore_actionでrequest.formatを明示的に設定します。format.Xブロックの並び替えだけでは*/*フォールバックを上書きできません。 - Cloudflare Workers:
request.headers.get('Accept')を手動でパース、またはacceptnpmパッケージを使用
インライン vs リダイレクト — インラインを選ぶ
エージェント向けコンテンツを提供する方法は2つあります。インラインの方が優れています:
インライン(推奨): 同じURLが Accept に応じて異なるボディを提供。
GET / → 200 OK
Content-Type: text/html (ブラウザ) | text/markdown (エージェント)
リダイレクト(レガシー): エージェントを /llms.txt に送る。
GET / → 302 Found, Location: /llms.txt
GET /llms.txt → 200 OK
インラインが勝つ理由:(1) 2回ではなく1回のフェッチ — レイテンシ半減;(2) エージェントがユーザーに報告するURLはユーザーが尋ねたURL自身であり、リダイレクト先ではない;(3) Vary: Accept でキャッシュがよりクリーン。/llms.txt ルートは直接フェッチするツール向けに引き続き存在 — 両方のルートが同じコンテンツ関数を呼び出します。
Vary: Accept は重要
同じURLが Accept に応じて異なるボディを返す場合は常に Vary: Accept を設定します。これにより共有キャッシュ(CDN、プロキシ、ブラウザ)に、キャッシュキーに Accept ヘッダーの値を含める必要があると伝えます。
これがないと、CDN は1つのエージェントフェッチからの markdown レスポンスをキャッシュし、ブラウザ訪問にそれを提供する — またはその逆 — 可能性があります。Vary ヘッダーは、ボディが異なるときにキャッシュエントリが交換可能にならないようにする唯一のものです。
既知の AI エージェント User-Agent
| エージェント | User-Agent | 目的 |
|---|---|---|
| ClaudeBot | Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com) | Anthropic 訓練クローラー |
| Claude-User | Mozilla/5.0 ... (compatible; Claude-User/1.0; +Claude-User@anthropic.com) | claude.ai web_fetch、Claude API web_search ページ読み取り |
| Claude-SearchBot | (文字列未公開) | Anthropic 検索インデックスクローラー |
| claude-code | claude-code/<version> | Claude Code CLI WebFetch ツール |
| ChatGPT-User | Mozilla/5.0 ... (compatible; ChatGPT-User/1.0; +https://openai.com/bot) | ユーザー主導 ChatGPT ブラウズ |
| OAI-SearchBot | Mozilla/5.0 ... (compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot) | OpenAI 検索インデックス |
| OAI-AdsBot | Mozilla/5.0 ... (compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot) | OpenAI 広告クローラー |
| GPTBot | Mozilla/5.0 ... (compatible; GPTBot/1.3; +https://openai.com/gptbot) | OpenAI 訓練クローラー |
| PerplexityBot | Mozilla/5.0 ... (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot) | Perplexity 検索結果クローラー |
| Perplexity-User | Mozilla/5.0 ... (compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user) | ユーザー主導 Perplexity フェッチ |
| Google-Extended | (Googlebot UA を使用) | Google Gemini 訓練、robots.txt で制御 |
Web Bot Auth — 次のシグナル
増加するエージェントリスト(ChatGPT Agent は本日確認済み;Anthropic、Perplexity、Google が予定)が、RFC 9421 HTTP Message Signatures に従ってリクエストを暗号学的に署名します。シグナルは Signature-Agent リクエストヘッダーです:
Signature-Agent: "https://chatgpt.com"
Signature-Input: sig=("@authority" "signature-agent"); keyid="..."; tag="web-bot-auth"
Signature: sig=...
受信リクエストに Signature-Agent が見える場合、UA がブラウザのように見えてもクライアントを既知のエージェントとして扱います。完全な検証のために、Signature-Agent で指定されたホスト(/.well-known/http-message-signatures-directory)から JWKS をフェッチし、web-bot-auth npm パッケージで署名を検証します。コンテンツネゴシエーション目的では、ヘッダーの存在だけで十分なソフトシグナルです。
さらに学ぶ
- RFC 9110 §12.5 — コンテンツネゴシエーション
- llms.txt 仕様
- Anthropic クローラードキュメント
- OpenAI ボットドキュメント
- Perplexity クローラードキュメント
- Cloudflare Web Bot Auth
優先タイプ vs 非 HTML — 次のバー
「エージェント UA は非 HTML を取得」は基本チェックです: サーバーは HTML 以外のものを提供しましたか?「優先 Content-Type を返す」は厳密版です: レスポンスの Content-Type は、クライアントがシグナルした先頭タイプと一致しましたか?
例えば、クライアントが Accept: text/markdown, text/html, */* を送信したとき:
- サーバーが
Content-Type: text/markdownを返す → 両方のチェックに合格 - サーバーが
Content-Type: text/plainを返す → 基本チェックに合格、厳密チェックに失敗 - サーバーが
Content-Type: text/htmlを返す → 両方失敗
スキャナーは 4 つのプローブを実行します: markdown 先頭 (Claude Code / Cursor のパターン)、HTML 先頭で markdown を 2 番目にリスト (ブラウザ / ChatGPT Agent のパターン — クライアント順序を無視してサーバー側の優先順位を使用するサイトを検出)、HTML を markdown より優先する明示的な q値 (q値を完全に無視するサイトを検出)、JSON 先頭 (プログラマティック検出パターン)。4 つすべてが合格する必要があります。今日、Content-Type ラベルは LLM ベースのエージェントにとってほとんど装飾的です — MIME タイプに関係なく、本体のバイトを解析します。しかし、ブラウザベースの AI 拡張機能と新興の MCP ツールは Content-Type で分岐し、エコシステムが成熟するにつれてギャップは広がります。
修正はハンドラー内の 1 行の変更です: ハードコードされた値ではなく、ネゴシエートされたタイプからレスポンス Content-Type を設定します。コードが Accept: text/plain と Accept: text/markdown の両方に対して text/plain を返している場合は、ネゴシエートされたタイプで分岐し、適切にラベル付けします。
このチェックは必須です — 失敗するとコンテンツネゴシエーショングループでポイントを失います。
バグの診断 — q値と3つのパターン
q値の仕組み
クライアントが Accept で複数のタイプを送信する場合、0.0 から 1.0 の q値 (品質係数) を付加して相対的優先度を表現できます:
Accept: text/markdown;q=1.0, text/html;q=0.5, */*;q=0.1
意味: 「markdown が本当に欲しい。バックアップとして HTML を受け入れる。それ以外は最後の手段。」
q値が指定されていない場合、デフォルトは 1.0 です。したがって Accept: text/markdown, text/html, */* は 3 つすべてが等しく優先されることを意味します — そしてヘッダー内の順序がタイブレーカーになります。正しいサーバーは markdown を選択します。
適切な Accept ネゴシエーター (Express req.accepts()、npm の negotiator パッケージ、Python werkzeug、Go goautoneg) はこれらすべてを自動的に処理します: q値の解析、タイ時の順序尊重、サーバーが提供できる最良の一致の選択。
実環境で見られる 3 つのバグパターン
サイトが「エージェント UA は非 HTML を取得」チェックに失敗した場合、原因はほぼ常に次のいずれかです:
パターン 1: 部分文字列マッチング。 Accept ヘッダーが特定のタイプを含むかどうかを固定の if-else 順序でチェックするコード。例:
// 誤り — Accept の順序ではなく、チェックの順序が勝つ
if (accept.includes('text/html')) return html;
else if (accept.includes('text/markdown')) return markdown;
クライアントが Accept: text/markdown, text/html を送信 → text/html が文字列内にあるため、サーバーは HTML を返します。クライアントの優先順位は完全に無視されます。
パターン 2: */* で HTML を提供するフレームワークのデフォルト。 一部のフレームワークは、明示的な非 HTML タイプが先にリストされていても、Accept 内の */* を HTML にフォールバックするライセンスとして扱います。Rails 8 の respond_to は注目すべき例です:
Accept: text/markdown, */* → Rails は HTML を返す (markdown 無視)
Accept: text/markdown, text/html → Rails は markdown を返す (*/* なし、順序尊重)
パターン 3: サーバー内部の優先順位 + q値の無視。 サーバーには独自の優先順位リスト (どこかにハードコードされていることが多い) があり、Accept ヘッダーからサーバーのリスト上で最も高いタイプを選びます — クライアントのリスト上ではありません。q値はまったく解析されません:
Accept: text/plain;q=0.9, text/html;q=0.5 → HTML を返す
(q値が明示的に plain を優先しているにもかかわらず
サーバーは html を優先する)
パターン 3 の決定的証拠は、q値が無視されていることです。同じサイトが上の行で HTML を返し、Accept: text/plain, text/markdown (plain が最初にリストされているにもかかわらず markdown が勝った) で markdown を返す場合、それはパターン 3 です。
簡単な診断テスト
ホームページに対してこれら 5 つの curl コマンドを実行してください。レスポンスのパターンが、どのバグがあるかを教えてくれます:
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/
- 最初のもののみが markdown を返す場合: パターン 1 (部分文字列マッチング)。
- 最初の 3 つが markdown を返すが 4 番目が HTML を返す場合: パターン 2 (
*/*フォールバック)。 - 5 番目が HTML を返し 3 番目が markdown を返す場合 (またはあなたが提供すると思っているものの逆): パターン 3 (サーバー優先 + q値無視)。
修正方法は 3 つのケースすべてで同じです: アドホックな選択ロジックを上記のリストの適切な Accept ネゴシエーターに置き換えてください。
インライン vs 302 リダイレクト — どうすべきか
ホームページでエージェント向けコンテンツを提供する 2 つのパターン:
インライン — 同じ URL が Accept ヘッダーに応じて異なるボディを返します。
GET / + Accept: text/html → 200 + HTML
GET / + Accept: text/markdown → 200 + markdown
リダイレクト — サーバーはエージェントリクエストを別の正規 URL に送信します。
GET / + Accept: text/markdown → 302, Location: /llms.txt
GET /llms.txt → 200 + markdown
インラインを使用してください。 これは RFC 9110 §12.2 で文書化されたベストプラクティスであり、リダイレクトベース (リアクティブ) ネゴシエーションの欠点を明示的に列挙しています: 「代替リストの送信に苦しみ... 代替表現を取得するために 2 回目のリクエストが必要」、「自動選択をサポートするメカニズムを定義していません」。
テストしたすべての主要なコンテンツネゴシエーション対応サイトはインラインを使用しています:
- GitHub API — 同じ URL が Accept で変化、リダイレクトなし
- Stripe docs —
docs.stripe.com/apiは同じ URL から HTML または markdown をVary: Acceptで返す - Cloudflare developer docs — エッジ変換、同じ URL
- Vercel、Mintlify、Sanity — すべて公開ガイダンスでインラインを推奨
なぜインラインが具体的に優れているか
- レイテンシ半分。 2 回ではなく 1 回の HTTP フェッチ。HTTP/2 および HTTP/3 のマルチプレキシングはリダイレクトコストを排除しません。
- URL の忠実性。 エージェントがユーザーに報告する URL は、ユーザーが尋ねた URL です。302 では、エージェントは
/llms.txt(異なる URL) で終わります。 - クリーンなキャッシュ。 インラインと
Vary: Acceptにより、キャッシュは両方の表現を 1 つの URL キーで保存できます。 - エージェント専用のマジック URL がない。 インラインは URL 空間を統一されたままにします。
AgentGrade が確認すること
Inline content negotiation チェックはエージェント形式のリクエスト (claude-code/1.0.0 UA + Accept: text/markdown, text/html, */*) を送信し、レスポンスがブラウザリクエストとは異なる URL で終わらないことを確認します。普遍的なリダイレクト (HTTPS アップグレード、末尾スラッシュ正規化) はペナルティを受けません — エージェント固有のリダイレクトのみです。
修正方法
302 ロジックをインラインネゴシエーションに置き換えます。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');
});
/llms.txt ルートは別の URL として引き続き存在できます — 両方のルートが同じコンテンツ関数を呼び出します。
このチェックは新興 (オプション) です — 今日は 302 を使用するサイトをペナルティしません。業界でインラインの採用が十分に広まったときに必須に昇格します。