先に結論です。Claude Code は、あなたが 1 回送るたびに、会話の全文を 1 つの API リクエストとして送り直しています。 モデルはリクエストの間で何も覚えていません。system prompt も、指示ファイルも、これまでの会話も、ツールの出力も、毎回まとめて送られます。本編が「足すほど薄まる」と言い、「索引だけが読まれる」と言い、「hook は読まれなくても落ちる」と言ったことは、全部このリクエストの中身の話です。

この章は、そのリクエストを解剖します。何がどの部分に載るか、1 ターンの中で hook がどこに割り込むか、ツールの出力はどこまで届くか、そして記録には何が残らないか。読み終えたら、本編の各回が「なぜそこに置いたか」を、リクエストの側から言い直せるはずです。

A.1 リクエストの 3 つの部分

Claude Code は、リクエストの中身を「変わりにくいものが先」になるよう並べています。公式ドキュメントは 3 つの部分で説明しています。

部分中身変わるとき
system prompt中核の指示、ツール定義、出力スタイル読み込まれるツール定義の集合が変わったとき、Claude Code を更新したとき
project contextCLAUDE.md、auto memory(MEMORY.md)、パス指定の無いルールセッション開始時、/clear/compact の後
conversationあなたのメッセージ、Claude の応答、ツールの結果毎ターン
flowchart TB
  subgraph R["1 回の API リクエスト(毎ターン、全部を送り直す)"]
    direction TB
    S["system prompt 層<br/>中核の指示 / ツール定義 / 環境情報 / git の状態 / 出力スタイル"]
    P["project context 層(user メッセージ)<br/>CLAUDE.md / MEMORY.md 先頭 200 行 / paths 指定の無い rules"]
    C["conversation 層<br/>あなたの発言 / Claude の応答 / tool_use と tool_result / hook の出力 / スキル本文"]
    S --> P --> C
  end
  R --> M["モデル"]
  M --> O["応答 = テキスト か tool_use"]
  O -.->|"tool_result を末尾に足して、次のリクエスト"| R

順序に理由があります。API はリクエストの先頭からの一致(prefix)でキャッシュを引きます。一致は完全一致で、前のほうが 1 文字でも変わると、それより後ろは全部作り直しになります。会話層が伸びるだけなら、前の 2 つはキャッシュから読まれます。system prompt が変わると、全部が無効になります。

この設計が、本編で書いた挙動のいくつかを決めています。指示ファイルをセッションの途中で直しても反映されないのは、CLAUDE.md が起動時に 1 度読まれてメモリに保持され、次の /clear/compact・再起動まで読み直されないからです。スキルや plan mode の指示は会話層のメッセージとして足されるので、キャッシュを壊しません。hook の出力も同じで、会話層の末尾に足されます。

A.2 1 ターンの流れと、hook が割り込む位置

本編の hook は「入口」「直後」「終わり際」に置かれていました。1 ターンの流れの上で、それぞれがどこに立っているかを図にします。

sequenceDiagram
  participant U as あなた
  participant H as hook(あなたのスクリプト)
  participant CC as Claude Code(ハーネス)
  participant M as モデル
  participant T as ツール(Bash など)

  U->>CC: プロンプト
  CC->>H: UserPromptSubmit(stdin に JSON)
  H-->>CC: exit 0 + stdout → 会話層に足される / exit 2 → プロンプトを止める
  CC->>M: リクエスト = system prompt + project context + 会話(+ hook の stdout)
  M-->>CC: tool_use(例: Bash "rails test")
  CC->>H: PreToolUse(tool_input を含む JSON)
  H-->>CC: exit 0 → 実行 / exit 2 → 実行せず、stderr をモデルに返す
  CC->>T: 実行
  T-->>CC: 出力(30,000 字までが inline)
  CC->>H: PostToolUse(tool_input + tool_response)
  H-->>CC: additionalContext / decision: block + reason → モデルに返す
  CC->>M: 次のリクエスト = 前回の全部 + tool_result(prefix はキャッシュ)
  M-->>CC: テキスト(応答の終わり)
  CC->>H: Stop
  H-->>CC: exit 2 / block → 止めずに続けさせる(stop_hook_active で 2 度目を区別)
  CC-->>U: 表示

図の要点は 3 つです。

  • hook はモデルの外で動き、結果は会話層に足される。 モデルが hook を「呼ぶ」のではなく、ハーネスがイベントのたびに走らせます。だから読まれなくても走ります(第 1 部 第 6 回)
  • 止める hook は、モデルの応答を「その場で」返す。 exit 2 の stderr は、次のリクエストの末尾に tool_result 相当として載ります。人間が会話に割り込むのと違って、モデルが今やった tool_use と地続きに届きます(第 1 部 第 11 回)
  • PostToolUse は「ツールは済んだ後」に届く。 保存を止める hook ではなく、保存した差分を読ませる hook になるのは、この位置の性質です(第 1 部 第 7 回)

A.3 system prompt に何が入っているか

system prompt の全文は公開されていません。公式ドキュメントが書いている範囲で、中身はこうです。

  • 中核の指示 —— 挙動・ツールの使い方・応答の形式。公式のシミュレーションでは約 4,200 トークン。「あなたが見ることはない」と書かれています
  • ツール定義 —— 組み込みツールの定義(JSON スキーマ)。MCP のツールは既定では名前だけが載り、スキーマは必要になったときに tool search で読み込まれます
  • 環境情報 —— 作業ディレクトリ、プラットフォーム、シェル、OS のバージョン、git リポジトリかどうか。約 280 トークン
  • git の状態 —— ブランチ、status、直近のコミット。system prompt の末尾に別ブロックとして載ります
  • 出力スタイル —— /config で選んだもの。起動時に 1 度だけ読まれます

Agent SDK のドキュメントは、CLI と同じ system prompt を claude_code プリセットと呼び、「ツールの使い方の指示、セキュリティと安全の指示、作業ディレクトリと環境の文脈」を含むと書いています。中身を知る公式の手段はこのプリセットの説明と、--append-system-prompt足すことだけです。

ツール定義がこの部分にあることは、本編の 2 箇所に効いています。第 2 部 第 4 回が引いた Anthropic の指針(ツールの説明は極めて詳細に)は、この部分に載るテキストの話です。毎ターン送られ、キャッシュされ、モデルが tool_use を決めるときに読みます。一方、本編の用途カタログはツール定義ではなく、hook が読むファイルです。モデルには「止まったときの案内」としてしか届きません。同じ「説明を厚く」でも、載る部分が違います。

環境情報が system prompt に埋まっていることには、副作用があります。キャッシュは、実質的に 1 台のマシンの 1 つのディレクトリに閉じます。 同じリポジトリでも、worktree が違えば作業ディレクトリが違い、prefix が違うので、キャッシュを共有しません。同じディレクトリで並列実行する 2 つのセッションは、prefix が一致するので互いのキャッシュを読みます。

A.4 user メッセージとして届くもの

本編の主戦場はここです。指示ファイルは system prompt ではありません。 公式ドキュメントは、はっきり書いています。

CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself.

Agent SDK のドキュメントも同じことを別の角度から書いています。環境情報を system prompt から user メッセージへ動かす excludeDynamicSections というオプションの注意書きに、「user メッセージの中の指示は、system prompt の同じ文よりわずかに重みが弱い」とあります。指示ファイルは、最初からその弱いほうの部分にいます。

user メッセージとして届くものを、届く時点で並べます。

届くものいつ量の目安・上限
CLAUDE.md(管理ポリシー → ユーザー → プロジェクト → ローカルの順に連結)セッション開始時1 ファイル 4 MiB まで読む。200 行未満が目標。HTML コメントは除かれる
パス指定の無い .claude/rules/*.mdセッション開始時CLAUDE.md と同じ優先度
MEMORY.md(auto memory の索引)セッション開始時先頭 200 行か 25KB のどちらか早いほう。それより後ろは載らない
スキルの descriptionセッション開始時description と when_to_use を合わせて 1,536 文字で切られる。disable-model-invocation: true のスキルは載らない
hook の stdoutUserPromptSubmitSessionStart などの限られたイベントだけexit 0 の平文 stdout が「Claude が見て動ける文脈」として足される。他のイベントの stdout はデバッグログ行き
hook の additionalContext / decision: block の理由PostToolUse などで JSON を返したときそのフィールドだけが会話に入る
読んだファイル、コマンドの出力ツールを使うたびtool_result として会話層に積まれる。あなたの端末には 1 行しか出ないが、Claude には全文が届く(上限は A.5)
パス指定のあるルール一致するファイルを読んだとき読んだ時点で会話層に足される
スキルの本文呼ばれたときuser メッセージとして会話層に足される
ファイル変更の通知読んだファイルが後で変わったとき<system-reminder> として会話層に足される

表の最後の行にある <system-reminder> は、Claude Code が会話の中に足す system 由来の文脈の入れ物です。私の手元では、CLAUDE.md の中身もこの入れ物に包まれて、最初の user ターンに同梱されていました。

一つ、私が手元で確かめて分かったことを書いておきます。セッションの記録(transcript の JSONL)には、この同梱分は残りません。 記録に残る最初の user メッセージは、私が打った 12 文字だけでした。system prompt も、指示ファイルの写しも、記録には無い。あるのは会話層だけです。本編が「読んだかどうかは測れない」と決めた線引きは、記録の側から見ても同じです。AI が指示ファイルを読んだかどうかは、記録のどこにも残っていません。

逆に、記録に残るものもあります。あなたが打った言葉は、type: "user" の行として全部残ります。 第 3 部 序論は「毎回打っている言葉には行数が無い」と書きましたが、正確には、ファイルには残っていて、数えていなかっただけです。数え方は A.9 の検証手順 5 に置きます。

A.5 ツールの出力は、どこまで届くか

第 1 部 第 1 回は「AI はツール出力を読み飛ばせない。26,147 行が全部コンテキストに入る」と書きました。この文には、Claude Code の側で 1 つ条件が付きます。Bash の出力には inline の上限があります。

公式ドキュメントの「Output limits」は、こう書いています。コマンドの出力は作業ファイルに流され、終わったら読み戻される。読み戻す窓は BASH_MAX_OUTPUT_LENGTH(既定 30,000 文字、上限 150,000 文字)。そして Claude に何が届くかは、結果が成功か失敗かで変わります。

結果Claude に届くもの
成功(exit 0)inline は約 30,000 文字まで。超えたら、セッションディレクトリに保存したファイルのパス + 先頭の短いプレビュー。残りは Claude が必要なときにそのファイルを読むか検索する
失敗(exit ≠ 0)inline は約 10,000 文字まで。超えたら、読み戻した窓から切った先頭と末尾の抜粋。ファイルのパスは付かない

つまり、素の rails test を打った場合に起きることは、版によって 2 通りあります。上限の無い版なら全文が入り、上限のある版なら「先頭と末尾だけ」が入ります。どちらも本編の主張を弱めません。全文が入れば序論の「薄まる」が起き、抜粋なら真ん中の失敗が見えないままモデルが判断します。テストは失敗すると exit が 0 ではないので、届くのは後者の 10,000 文字の頭と尻です。13,950 件の出力で、失敗が真ん中にあれば、モデルはそれを見ずに「通った」と読みかねません。

第 1 部 第 1 回のラッパーは、この条件の下でも同じ理由で要ります。要約は数行なので上限に掛からず、失敗の抜粋は grep -A 3 で先頭に寄せられ、全文は自分の決めた場所(tmp/test_last.log)に残ります。ハーネスが勝手に保存する場所ではなく、あとから実行せずに読み直せる場所です。

A.6 数の目安

数字は、公式の「Explore the context window」に載っているシミュレーションの値です。あなたの環境の実測ではありません。組み込みツールの数、有効なプラグイン、MCP サーバーで変わります。手元の値は /context で見ます。

起動時に載るものトークンの例
system prompt(中核の指示)4,200
MEMORY.md(auto memory の索引)680
環境情報280
MCP ツール(名前だけ。スキーマは遅延)120
スキルの description450
~/.claude/CLAUDE.md320
プロジェクトの CLAUDE.md1,800
起動時の合計約 7,850
作業中に足されるものトークンの例
ファイルを 1 つ読む(Read1,100〜2,400
grep の結果600
パス指定のルールが 1 つ載る290〜380
PostToolUse hook が additionalContext を返す100〜120
npm test の出力1,200
サブエージェントの結果(最終メッセージ + トレーラー)420
スキルを 1 つ呼ぶ(本文)620

見るべきは合計ではなく比です。起動時の約 7,850 のうち、あなたが書いたもの(CLAUDE.md 2 つ + MEMORY.md)は約 2,800 で、残りはハーネスのものです。作業中に足されるものは、ファイルを 3 つ読めば起動時の合計を超えます。「足すほど薄まる」の分母は、あなたの指示ファイルではなく、会話層のほうで膨らみます。

A.7 本編との対応

  • 第 1 部 序論「足すほど薄まる」 —— 指示ファイルもメモリの索引も、読んだファイルも、同じ 1 つのリクエストの中で注意を奪い合っています。部分が違っても、モデルから見れば 1 本の入力です。「ルールの一覧を hook で毎回注入して約 26KB」は、UserPromptSubmit の stdout が毎ターン会話層に足されていた状態で、キャッシュは壊さない代わりに、読む量が毎ターン増えていました
  • 第 1 部 第 1 回「テスト結果を読まない」 —— tool_result は会話層に積まれます(A.5 の上限つき)。ラッパーは載る量を数行にし、hook の exit 2 は stderr を tool_result 相当として返します。指示ファイルの 1 行より効くのは、届く位置が「今やった tool_use の直後」だからです
  • 第 1 部 第 2 回「メモリを読まない」・第 2 部 第 1 回「提案を検討しない」 —— 起動時に載るのは MEMORY.md の先頭 200 行か 25KB だけで、本文のファイルは Claude が開くと決めたときにしか会話層へ入りません。第 2 部 第 1 回の「索引に名前があることと、本文が読まれることは違う」は、A.4 の表の 3 行目と 7 行目の差です
  • 第 1 部 第 6 回「ルールを読まない」 —— hook は会話層に何も足しません。出力を返したときだけ足されます。指示ファイルの行を hook に移すと、その行のぶんの入力が毎ターン消えます
  • 第 1 部 第 4 回「スキルを使わない」 —— スキルは description だけが毎ターン載り、本文は呼ばれたときだけです。disable-model-invocation: true にすると description も載りません。84 本のスクリプトに説明文を書かず、止める価値のある入口だけを hook に寄せたのは、毎ターン載る description を増やさない選択でもあります
  • 第 1 部 第 11 回「口を挟まない」 —— 途中で打った言葉は、現在のツール呼び出しが終わった後に読まれ、会話層に残り続けます(圧縮まで毎ターン送られる)。hook の案内は同じ位置に届いて、次のターンには tool_result の 1 つとして流れていきます
  • 第 2 部 第 4 回「入力だけは、確かめろ」 —— Anthropic の「ツールの説明は詳細に」は system prompt 層のツール定義の話で、用途カタログは hook が読むファイルです。カタログの description を 1 行にしたのは正しく、厚くした「契約」がモデルに届くのは止まったときの案内経由です
  • 第 3 部 序論「打っている言葉は数えられない」 —— transcript JSONL には残っています。A.9 の 5 で数えます
  • 第 3 部 第 3 回「不具合詳細を書かない」 —— 貼ったスクリーンショットは画像として会話層に入ります。画像には枚数と総サイズの上限があり、超えると古いものから一括で外されます(A.8)
  • 第 3 部 第 5 回「念を押さない」 —— 強調は同じ部分の中の相対順位です。部分の重みは system prompt が上で、指示ファイルは最初からその下にいます

A.8 本編が言っていない注意

ツール出力の上限は、ラッパーの exit code で見え方が変わります。 A.5 のとおり、失敗した(exit ≠ 0 の)コマンドの出力は 10,000 文字の頭と尻だけで、ファイルのパスも付きません。ラッパーがテストの exit status をそのまま返す設計(第 1 部 第 1 回)なら、失敗時に届くのはラッパーの要約だけです。要約が 10,000 文字を超える設計にしないでください。要約の末尾に「実行していない領域」の一覧を毎回出す形(手元では 22 行)も、この予算の中です。

hook の出力が会話層に入るイベントは限られています。 UserPromptSubmitUserPromptExpansionSessionStartPostModelSwitch の平文 stdout は文脈として足されます。PreToolUsePostToolUse の exit 0 の stdout は、デバッグログに書かれるだけで Claude には届きません。届けたいなら JSON の additionalContext を返します。第 1 部 序論で「ルールの一覧を hook で毎回注入していた」と書いた hook は UserPromptSubmit に置いたもので、届いていたのはそのイベントだったからです。

同じ内容でも、毎ターン足せば毎ターン読まれます。 本編で「約 26KB がコンテキストに入る状態まで行った」と書いたルールの一覧は、prefix cache を壊しません。会話層の末尾に足されるだけだからです。ただし、会話層は毎ターン送られるので、読む量は増え続けます。キャッシュは「計算し直す量」を減らすだけで、「読む量」は減らしません。その 22 行も同じ勘定に乗っています。

画像は枚数で外れます。 API は 1 リクエストに載せられる画像と PDF の数と総サイズに上限を持ち、Claude Code はそれを超える前に古いものから一括で外します。外れた画像は Claude から見えなくなります。第 3 部 第 3 回の「管理画面を丸ごと貼る」は、スクリーンショットを何件も続けて貼ると、最初の件が消えている状態で判断させることになります。1 件ずつ貼って、済んだら次へ進んでください。

キャッシュの範囲はディレクトリです。 worktree を分けた並列実行(第 1 部 第 7 回)は、キャッシュを共有しません。1 セッション目が温めたキャッシュを 2 セッション目が読めるのは、同じディレクトリで走らせたときだけです。費用を見るなら、並列実行の形で変わります。

環境情報を user メッセージへ動かせますが、重みが下がります。 Agent SDK の excludeDynamicSections(CLI では --exclude-dynamic-system-prompt-sections)を使うと、作業ディレクトリなどが最初の user メッセージへ移り、複数のマシンで system prompt を揃えられます。公式の注意書きは、user メッセージの指示は system prompt より「わずかに弱い」と言っています。本編が指示ファイルについて言ってきたことの、公式による裏付けです。

バージョン依存。 上の数値と挙動は 2026-09-05 に Claude Code 2.1.260 のドキュメントで確認したものです。inline の上限 30,000 / 10,000 文字、tool search による MCP スキーマの遅延読み込み、スキル description の 1,536 文字、MEMORY.md の 200 行 / 25KB は、いずれも版で変わりうる値です。数字を信じる前に A.9 で測り直してください。

A.9 検証手順

止まるべきものが止まり、通るべきものが通る。本編と同じで、両方向を 1 度ずつ確かめます。

1. /context で、起動時に載っているものを見る。 Memory files に CLAUDE.md と MEMORY.md が並ぶこと、各ブロックのトークン数が出ることを見ます。A.6 の表と桁が合っていれば、あなたの版でも同じ層構造です。

2. hook の stdout が届くイベントと、届かないイベントを対照する。 同じスクリプトを UserPromptSubmitPostToolUse に登録し、どちらの目印を Claude が答えられるかを見ます。

{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "echo MARKER_UPS_7b3f" }] }],
    "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo MARKER_PTU_9c1d" }] }]
  }
}

設定したら、AI に「ls を 1 回実行してから、この会話の中に MARKER_ で始まる文字列があれば全部書き出して」と頼みます。期待する結果は、MARKER_UPS_7b3f は書き出され、MARKER_PTU_9c1d は書き出されないことです。前者が出なければ hook が動いていません(設定の置き場所を疑う)。後者が出たら、あなたの版は PostToolUse の平文 stdout をモデルへ渡しています(公式の記述と違うので、版を控えて確かめ直す)。

3. prefix cache を数字で見る。 同じディレクトリで 2 回続けて走らせ、キャッシュの読み出しが増えることを見ます。

# 1 回目: キャッシュを書く側が大きい
claude -p "hello" --output-format json | jq '.usage | {cache_creation_input_tokens, cache_read_input_tokens}'
# 2 回目(5 分以内): 読む側が増える
claude -p "hello" --output-format json | jq '.usage | {cache_creation_input_tokens, cache_read_input_tokens}'
# 陰性対照: 別のディレクトリでは prefix が違うので、読む側は増えない
(cd /tmp && claude -p "hello" --output-format json | jq '.usage.cache_read_input_tokens')

2 回目の cache_read_input_tokens が 1 回目より大きく、別ディレクトリでは小さいままなら、A.1 の層構造と A.3 のキャッシュの範囲はあなたの版でも同じです。

4. InstructionsLoaded hook で、指示ファイルが読まれる時点を記録する。 起動時に読まれるものと、ファイルを読んだときに読まれるものが分かれることを見ます。

{
  "hooks": {
    "InstructionsLoaded": [{ "hooks": [{ "type": "command",
      "command": "jq -r '[.hook_event_name, .load_reason // \"\", .file_path // \"\"] | @tsv' >> tmp/instructions_loaded.tsv" }] }]
  }
}

セッションを開始し、paths: 指定のあるルールに一致するファイルを 1 つ読ませてから、tmp/instructions_loaded.tsv を見ます。期待するのは、CLAUDE.md の行が session_start で先に出て、ルールの行が path_glob_match で後から出ることです。ルールの行が起動時に出ていたら、その paths: は効いていません。

5. 記録に残るものと残らないものを、transcript で数える。 第 3 部 序論の「打った言葉を数える」を、手で数える代わりにスクリプトで数えます。同時に、system prompt と CLAUDE.md の写しが記録に無いことも見ます。

#!/usr/bin/env python3
# count_user_turns.py — transcript JSONL から「あなたが打った言葉」だけを数える
# 使い方: python3 count_user_turns.py ~/.claude/projects/<project>/<session>.jsonl
import json, sys

typed, tool_results, has_claude_md = [], 0, False
for line in open(sys.argv[1], encoding="utf-8"):
    try:
        entry = json.loads(line)
    except json.JSONDecodeError:
        continue
    if entry.get("type") != "user":
        continue
    content = entry.get("message", {}).get("content")
    blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content or []
    for block in blocks:
        if block.get("type") == "tool_result":
            tool_results += 1            # ツールの結果も user ロールで届く(モデルから見れば同じ部分)
        elif block.get("type") == "text":
            text = block.get("text", "")
            has_claude_md |= "# claudeMd" in text
            typed.append(text)

print(f"user text blocks: {len(typed)}  tool_result blocks: {tool_results}")
print(f"chars typed (incl. hook/system-reminder text if any): {sum(map(len, typed))}")
print(f"CLAUDE.md copy present in transcript: {has_claude_md}")
for text in typed[:5]:
    print("  >", text[:60].replace("\n", " "))

期待するのは 3 つです。user text blocks があなたが打った回数と一致すること。tool_result blocks がそれより桁で多いこと(会話層の大半はツールの結果です)。そして CLAUDE.md copy presentFalse であること。True なら、あなたの版は同梱分を記録に残しています。その場合、A.4 の「記録には残らない」はあなたの版には当てはまりません。

6. ツール出力の上限を、成功と失敗の両方で踏む。 40,000 文字の出力を成功と失敗の両方で出させ、届き方が変わることを見ます。

# AI に、この 2 つを順に実行させ、それぞれ「出力の何が見えたか」を報告させる
python3 -c 'print("x" * 40000)'                 # 成功: ファイルのパス + プレビュー が届くはず
python3 -c 'print("x" * 40000); exit(1)'        # 失敗: 頭と尻の抜粋だけ。パスは付かないはず

成功側で「保存先のファイルのパス」が報告され、失敗側で「先頭と末尾だけ見えた」と報告されれば、A.5 の表のとおりです。失敗側でも全文が見えたと報告されたら、あなたの版には失敗時の上限がありません。第 1 部 第 1 回の「全部入る」が、そのまま当てはまる版です。

A.10 出典(2026-09-05 確認)