何が提案されたのか
2026年9月3日、Anthropicの Alice Poteat 氏が anthropics/claude-code に 「Mods - make Claude 10x more extensible」というissueを立てました。 内容はClaude Codeのフックを、シェルスクリプトではなくTypeScriptの関数として書けるようにするという提案です。
提案には「Function Hooks: Core Architecture」という技術文書(2026年8月付)が添付されており、 9月9日の更新で製品名を「Claude Mods」に確定、 「数週間スケールで出す」と明言されました。 同時に、Claude Codeに組み込まれている3本のmodのソースが mods/ ディレクトリとして公開されています。
Function Hooks: Core Architecture
内部向けのアーキテクチャ文書が書かれる。フックを「Koa風のミドルウェア」として定式化し、$(エンジンインターフェース)による副作用の追跡を設計の中心に据えた8ページの文書。のちにissueへPDFとして添付される。
issue #91870 で公開提案
コミュニティからのフィードバックを集める形で提案が公開される。デモ動画9本とアーキテクチャ文書つき。「実装するかどうかはコミュニティの反応次第」という位置づけだった。
「Claude Mods」として出荷を明言
製品名をClaude Mods、実装プリミティブ名をfunction hookに確定。組み込みmod(diff / sec-default / telemetry)のソースを公開し、CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 での試用を公式に容認。既存機能を順次mod形式へ移していく方針も示された。
まだEarly Access
v2.1.273で実際に動作する。ただし公式ドキュメントサイトにmodsのページはまだなく、組み込みmodはリポジトリのmarketplaceにも掲載されない。「APIは予告なく壊れる」と明記された状態。
従来のシェルフックと何が違うのか
Claude Codeには以前からフックがあります。hooks.json に PreToolUse などのイベントと実行するコマンドを書いておくと、 そのタイミングでシェルスクリプトが走る、という仕組みです。 function hookは、これを置き換えるのではなく5つ目のフック種別として追加されるもので、 既存のcommand / prompt / agent / http フックはそのまま動き続けます。
違いは「何ができるか」よりも先に、フックがどこに位置するかにあります。 シェルフックはイベントのあとに反応するのに対し、function hookはイベントそのものを包みます。
| 観点 | 従来のシェルフック | function hook(Claude Mods) |
|---|---|---|
| 書くもの | シェルスクリプト(任意の言語の実行ファイル) | .ts / .js / .tsx のモジュール |
| 契約の相手 | 環境(プロセス・標準入出力・終了コード) | エンジン(型定義つきの関数シグネチャ) |
| 位置 | イベントの前か後に1回反応する | イベントを包む(前・後・最中・代替・書き換え) |
| 入力と結果 | どちらか一方しか触れないことが多い | e を書き換えて渡し、next(e) の結果も加工できる |
| 合成 | プロセス単位で独立。互いを知らない | 登録順に入れ子。外側が内側の結果を上書きできる |
| UI描画 | 不可 | ui.render でコンポーネントを差し替え・ラップできる |
| 型 | なし(JSONを自前でパース) | イベント名リテラルで e と戻り値が絞り込まれる |
| 実行環境 | ユーザーの権限でそのまま実行 | ambientなfs・ネットワークなし。$ 経由のみ |
最後の行が、この提案でいちばん効いている部分です。 シェルフックは「ユーザーの権限で任意のコマンドを実行する」ので、何をするかは実行してみるまで分かりません。 function hookはその逆で、プラグインができることは $ というオブジェクトに列挙されたメソッドがすべてになります。
中核①:フックはミドルウェアである
まず形から。hooks/hooks.json に modules キーを足し、指定したファイルで register(on) を export します。
{
"description": "npm を pnpm に書き換え、rm -rf を拒否するデモ mod",
"modules": ["./register.ts"]
}import type { On } from 'claude-code'
export function register(on: On) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/rm\s+-rf/.test(e.command)) {
return { deny: 'demo-mod: rm -rf は禁止です' }
}
if (!e.command.startsWith('npm ')) return next(e)
return next({ ...e, command: e.command.replace(/^npm /, 'pnpm ') })
})
}フックの引数は3つです。アーキテクチャ文書の定義をそのまま訳すと、次のようになります。
$(エンジンインターフェース) — フックが読める状態と、起こしてよい副作用のすべて。「世界」の別名。e(イベント) —$のメソッドに渡された引数そのもの。イミュータブルな素の値で、書き換えたければコピーをnextに渡す。next(継続) — このディスパッチの残り。呼べば次に登録されたフックが走り、チェーン全体の結果がPromiseで返る。1回呼んでも、何回呼んでも、呼ばなくてもよい。
next を呼ぶ位置を変えるだけで、フックは5通りの振る舞いをします。 シェルフックが「PreToolUse」と「PostToolUse」という2つの別イベントを必要としていたものが、1つのイベントの中での書き方の違いに畳まれます。
| 配置 | やること | 書き方(tool.call の場合) |
|---|---|---|
| before | 先に処理して、あとはチェーンに任せる | $.ui.log(...); return next(e) |
| after | 実行させてから結果に触る | const r = await next(e); ...; return r |
| during | チェーンを走らせつつ並行で作業する | const p = next(e); ...; return p |
| instead | 下をすべて無効化して自分が答える | return { deny: 'no tools allowed' } |
| modifying | イベントを書き換えて流す | return next({ ...e, timeout: 30 }) |
登録順がそのまま入れ子になる
複数のmodが同じイベントをフックしたときの合成規則は単純で、先に登録されたものが外側です。on(X, A)、on(X, B)、on(X, C) の順なら A(B(C(...))) に畳まれます。 コアの既定動作は最後に登録されるので、いちばん内側で、いちばん権限が小さい、という構造になっています。
flowchart TD
E["エンジンが $.tool.call(e) を呼ぶ"] --> A["mod A のフック<br/>最初に登録 = 最外周"]
A -->|"next(e)"| B["mod B のフック"]
B -->|"next(e)"| C["core プラグイン<br/>最後に登録 = 最内周"]
C --> R["実際にツールを実行"]
R --> C2["結果が core から返る"]
C2 --> B2["B が結果を加工できる"]
B2 --> A2["A が結果を加工できる"]
A2 --> OUT["エンジンに最終結果が返る"]
プラグインの並び順は組織の管理戦略そのものとして設計されています。 管理者が前に置いたプラグイン → 依存順のプラグイン → 管理者が後ろに置いたプラグイン、という順序で、 「どのプラグインをそもそもロードしてよいか」を決めるのもまた plugin.register イベントのフックです。 許可リストや拒否リストは、いちばん前に置かれた1本のmodとして表現できます。
中核②:$ がプラグインの唯一の出入り口
ここがこの設計のいちばん面白いところです。$ は「nounのオブジェクト」で、各nounが「eventのオブジェクト」になっています。 つまり呼び出しは常に $.noun.event(入力) という形をしていて、そのメソッド呼び出し自体がフック可能なイベントです。
だから「エンジンのイベント」と「プラグインの副作用」に区別がありません。 REPLは $.prompt.submit を呼び、クエリループは $.tool.call を呼び、描画箇所は $.ui.render を呼ぶ。 あなたのmodが $.fs.write(...) を呼べば、それは fs.write イベントとして、あなたより外側にいる管理者のmodのフックを通ります。 「プラグインの権限を制限する」ための特別な仕組みは要らず、既存のフック機構がそのまま監査と統制になります。
型定義(mods/types/claude-code.d.ts、約10,700行)を数えると、イベントは大きく2群あります。
| 群 | 数 | 例 | 意味 |
|---|---|---|---|
| エンジンのイベント | 34 | tool.call / prompt.submit / turn.start / ui.render / agent.spawn / session.compact / engine.create | エンジンが自分で起こす出来事。従来のフックが狙っていた領域 |
| オペレーションのイベント | 50 | fs.read / fs.write / http.fetch / process.run / store.set / clock.sleep / model.complete / ui.toast | プラグインが $ に対して行う副作用。これ自体がフック対象になる |
| classic.* | 既存イベント分 | classic.PreToolUse など | 従来のシェルフックも同じ仕組みに載り、function hookから包める |
engine.create だけは特別で、$ 自身を組み立てるイベントです。 各プラグインが「下から返ってきた $ に自分のnounを足す」という畳み込みになっていて、 組み込みの telemetry modはこれ1つで $.telemetry を生やしています。
on('engine.create', async ($, e, next) => {
const below = await next(e)
return { ...below, store: createStore(below) }
})逆に、足さずに減らすこともできます。 組織のmodは最外周=最後に返る側にいるので、「コアが返したnounのうち、名前を挙げたものだけを通す」と書けば、 それより内側のプラグインからそのnounが消えます。アーキテクチャ文書はこれを「blast-door(防爆扉)」と呼んでいます。
手元で動かしてみる(v2.1.273)
ここからは実際に動かした記録です。有効化は環境変数1つで、プラグインは --plugin-dir で読ませます。
# 最小構成
# demo-mod/.claude-plugin/plugin.json … 普通のプラグインマニフェスト
# demo-mod/hooks/hooks.json … "modules": ["./register.ts"]
# demo-mod/hooks/register.ts … register(on) を export
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir demo-mod検証1:イベントの書き換えはモデルに見えない
echo ping を echo MOD_REWRITE_OK に書き換えるだけのmodを書き、 同じプロンプトをフラグあり・なしで流しました。
import type { On } from 'claude-code'
export function register(on: On) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (e.command !== 'echo ping') return next(e)
return next({ ...e, command: 'echo MOD_REWRITE_OK' })
})
}### フラグあり
$ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "Bashツールで 'echo ping' を実行し、標準出力だけを一字一句そのまま報告して。" \
--plugin-dir demo-mod --allowedTools Bash
MOD_REWRITE_OK
### フラグなし(同じ --plugin-dir、同じプロンプト)
$ claude -p "Bashツールで 'echo ping' を実行し、標準出力だけを一字一句そのまま報告して。" \
--plugin-dir demo-mod --allowedTools Bash
pingモデルは echo ping を実行したつもりで MOD_REWRITE_OK を受け取っています。 そしてフラグがなければmodは静かにロードされないことも確認できます。--plugin-dir を渡しているのにエラーも警告も出ないので、動かないときはまずフラグを疑うことになります。
検証2:拒否は deny を返す
rm -rf を含むコマンドで { deny: '...' } を返すと、ツールは実行されず、メッセージがそのままモデルに返りました。
$ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "Bashツールで 'rm -rf sandbox' を実行して。拒否されたらそのエラーメッセージを一字一句そのまま報告して。" \
--plugin-dir demo-mod --allowedTools Bash
エラーが拒否されました。正確なエラーメッセージは以下の通りです:
demo-mod: rm -rf は禁止です
$ ls sandbox
a.txt # ← 消えていない検証3:登録順が入れ子になる
echo で始まるコマンドの末尾に自分の名前を追記するだけのmodを2本(mod-outer / mod-inner)用意し、 その順で --plugin-dir に並べて echo start を実行させました。
$ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "Bashツールで 'echo start' を実行し、標準出力を行の順序も保って報告して。" \
--plugin-dir mod-outer --plugin-dir mod-inner --allowedTools Bash
start
outer
inner先に置いた mod-outer の書き換えが先に適用され、その結果を mod-inner がさらに受け取っています。 ドキュメントの「先に登録されたものが外側」が、そのまま観測できました。
検証4:副作用が静的に列挙される
ここがいちばん印象的でした。claude plugin validate を走らせると、そのmodがどのイベントをフックし、$ の何を呼ぶかが一覧で出ます。
$ claude plugin validate demo-mod
❯ ./register.ts hooks: tool.call{tool=Bash}
❯ ./register.ts calls: nothing on $
$ claude plugin validate audit-mod
❯ ./register.ts hooks: tool.call
❯ ./register.ts calls: $.clock.now, $.store.set, $.ui.logaudit-mod は $.ui.log と $.clock.now と $.store.set を呼ぶだけのmodです。 インストール前に「このプラグインは何をするのか」が機械的に分かる、というのが $ 一本化の実利になります。
検証5:抜け道はふさがれているか
では $ を無視して直接ファイルを読んだら? node:fs をimportしてみると、validateで弾かれました。
$ claude plugin validate rogue-mod
✘ Found 1 error:
❯ modules../register.ts: rogue-mod: cannot import "node:fs" (from hooks/register.ts):
a hooks module imports its own files by relative path and "claude-code", nothing else
✘ Validation failedimport経路がないなら、グローバルはどうか。フックの中から typeof を取って deny メッセージに載せて返してみました。
PROBE typeof-fetch=undefined typeof-process=undefined example.com-status=error: ReferenceError: fetch is not definedfetch も process も存在せず、require も呼べません。 「ambientなファイルシステムもネットワークも持たない」という設計は、少なくともこの範囲では実際に守られていました。
検証6:フックがthrowしても止まらない
ここは運用上いちばん注意が要る挙動です。フックが例外を投げた場合、そのフックは飛ばされて処理は続行します。
export function register(on: On) {
on('tool.call', async ($, e, next) => {
throw new Error('BOOM from rogue-mod')
})
}$ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "Bashツールで 'echo hi' を実行して。" \
--plugin-dir rogue-mod --allowedTools Bash
成功しました。出力は以下の通りです:
hi中核③:UIも同じ仕組みで差し替えられる
Claude CodeのUIはReactで書かれています。そこで描画も ui.render というイベントとして扱い、コンポーネント単位でフックできるようにする、というのがこの提案のもう一つの柱です。
on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
const { Row, Badge } = $.ui.resolve(e)
const rendered = await next(e)
return (
<Row>
{rendered}
<Badge text="checked" />
</Row>
)
})ポイントは e.surface です。描画先は terminal / desktop / artifact などの「サーフェス」で、 サーフェスごとにJSXの変換先(Inkのツリー、DOM、Slackの Block Kit など)が違います。 modはサーフェスが提供する要素で描くだけで、変換はエンジン側が受け持ちます。 ボタンの押下も同じで、ui.press は1つのイベントとして、ターミナルのクリックもデスクトップアプリのクリックも同じフックに届きます。
これが机上の話でないことは、組み込みmodを見れば分かります。/diff パネルはすでにmodとして実装されています。 未コミットの変更をトランスクリプトの横に出し、Claudeの編集やシェル実行に合わせて更新し、 ホイールのスクロールやキーコードも自前で処理する——あの機能ひとつが、プラグインとして外から書ける範囲に収まっている、という実例です。
| 組み込みmod | 役割 | 座る位置 |
|---|---|---|
diff | /diff のペイン。ファイル単位のハンク表示、編集に追従する更新、比較ベースの切り替え | 組み込み |
sec-default | 組織の従来型フック・プロンプト内容・管理設定・ツールポリシーを、ユーザーが入れたプラグインの手の届かない場所に置く。自身は何のポリシーも足さない | 最外周(管理設定のあるマシン、Team / Enterprise組織) |
telemetry | engine.create で $.telemetry(log / mark)を生やす。CLIの分析が無効な環境では何も送らない | 組み込み |
現時点で踏みそうな落とし穴
- フラグがないと静かにロードされない。
--plugin-dirを渡してもエラーは出ません。CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1を忘れていないか最初に確認します。 - 公式ドキュメントのページがまだない。 執筆時点で
code.claude.com/docsのインデックスにmodsの項目はありません。一次情報はissue #91870、添付のアーキテクチャ文書、そしてmods/のソースと型定義です。 - APIは予告なく変わる。
mods/README.mdに明記されています。組み込みmodはリポジトリのmarketplaceにも掲載されていません。 - ドキュメントと手元のCLIにズレがある。
mods/README.mdはclaude plugin testでmodのテストを走らせると書いていますが、v2.1.273のclaude plugin --helpにそのサブコマンドはまだありません。 - チェーンは直列。 フックは
nextで畳まれるので、各フックの待ち時間はそのまま積み上がります。互いに独立した重い処理を並列に流したいなら、従来のコマンドフックのほうが向いています。 - throwは止めない。 前述の通りfail-openです。
既存の拡張手段とどう使い分けるか
Claude Codeの拡張手段は増え続けていて、CLAUDE.md、スキル、サブエージェント、MCP、シェルフック、そしてmodがあります。 modは「いちばん強いから常に使う」ものではなく、エンジンの挙動そのものを変えたいときにだけ使う道具です。
flowchart TD
S["拡張したいことがある"] --> A{"やりたいのは<br/>指示・知識を足すことか"}
A -->|"はい"| SK["CLAUDE.md / スキル<br/>モデルに読ませる"]
A -->|"いいえ"| B{"外部のデータや<br/>APIをつなぎたいか"}
B -->|"はい"| MCP["MCPサーバー<br/>ツールとして生やす"]
B -->|"いいえ"| C{"エンジンの挙動を<br/>変えたいか"}
C -->|"いいえ"| SK
C -->|"はい"| D{"イベントの後に<br/>一度反応すれば足りるか"}
D -->|"足りる"| SH["従来のシェルフック<br/>安定・並列・本番投入可"]
D -->|"足りない"| E{"入力の書き換え・結果の加工<br/>UI描画・副作用の統制が要るか"}
E -->|"要る"| MOD["Claude Mod<br/>ただしEarly Access"]
E -->|"要らない"| SH
まとめ
- ① フックの位置が「後」から「周り」に変わる。 function hookは
($, e, next)というKoa風のミドルウェアで、nextを呼ぶ位置を変えるだけでbefore / after / during / instead / modifyingの5通りに化ける。PreToolUseとPostToolUseという2イベントが、1イベントの中の書き方の違いに畳まれる。登録順がそのまま入れ子で、先に登録されたものが外側=強い。 - ②
$の一本化が、統制と監査を副産物にする。 プラグインの副作用は$.noun.event(...)以外にありえず、その呼び出し自体がイベントなので、外側のmodがフックできる。手元のv2.1.273ではclaude plugin validateが「フックするイベント」と「$ への呼び出し」を静的に列挙し、node:fsのimportはエラー、fetchとprocessはundefinedだった。権限制御のための特別な機構を足さずに、既存のフック機構がそのまま防爆扉になる設計。 - ③ まだ本番には早いが、理解は前倒しでよい。 Early Accessでドキュメントページもなく、APIは予告なく壊れる。throwは処理を止めない(fail-open)ので制御は
denyで書く。一方で/diffがすでにmodとして実装され、既存機能を順次mod形式へ移す方針が示されている以上、これはオプション機能ではなくClaude Codeの拡張レイヤーそのものの作り直しとして読むのが妥当。
理解度チェック
Claude Mods(function hooks)を試すには、環境変数 ____=1 を設定してClaude Codeを起動する必要がある(変数名で回答)。