$ claude plugin validate ./first-mod
❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
❯ ./register.js calls: $.command.register, $.ui.invalidate
✔ Validation passed
この hooks: と calls: の2行が、mod が何を聞いて何に触るかの全て。Claude Code v2.1.287(2026-10-01 公開)で入った Mods を手元の v2.1.288 で動かし、settings.json に書く従来の hooks とどこで順番が入れ替わるかを確かめました。
用語は公式に合わせます。settings.json や hooks/hooks.json に書く従来のフックを settings hook、mod の中の関数を mod hook と呼び分ける。Mods overview の “on these pages, “hook” means a mod’s handler, and the settings-file kind is a “settings hook”” に従った区別です。settings hook の5種類はHooks の prompt型/agent型で整理しました。
Claude Code Modsとは、settings hookと何が違うか
mod は JavaScript か TypeScript の関数を Claude Code のプロセス内で呼ばせるプラグインです。overview の冒頭に “A mod’s handlers are functions that run inside Claude Code instead” とあり、シェルや HTTP で外に出る settings hook との差はここだけ。プロセス内にいるので、pane の描画、/command の追加、ツール呼び出しの肩代わりまで手が届きます。公式の “Compare mods, settings hooks, skills, and MCP servers” を圧縮するとこうなる。
| Mod | settings hook | Skill | MCP server | |
|---|---|---|---|---|
| 何か | プラグイン内の関数。Claude Code が自プロセスで呼ぶ | シェルコマンド・HTTP・プロンプト | SKILL.md | 外部プロセス |
| 変えられるもの | ツール呼び出し・プロンプト・コマンド・ターン・画面 | ツール呼び出しの可否、引数と結果、追加コンテキスト | Claude の知識と手順 | Claude が持つツール |
| 画面に描けるか | 描ける | 描けない | 描けない | 描けない |
| 書くもの | JS / TS | スクリプトと settings.json | Markdown | 任意言語のサーバー |
hooks.json の modules キーがあれば mod になる
必須ファイルは .claude-plugin/plugin.json、hooks/hooks.json、hooks module の3つだけです。マニフェストに mod 専用のキーはなく、hooks.json に "modules": ["./register.js"] があるかどうかで決まります。公式の言い方は “having it is what makes the plugin a mod”。拡張子は .js .mjs .cjs .jsx .ts .mts .cts .tsx の8種で ES module 限定、require も動的 import() も読み込まれません。
Observe・Rewrite・Answer の3択
各 mod hook は ($, e, next) を受け取ります。next(e) をそのまま返せば観察、コピーを渡せば書き換え、next を呼ばずに値を返せば自分で答える。この3つ目が settings hook に無い動きで、後段の mod も Claude Code 本体の処理も走りません。Claude Code は e を deep freeze して渡すので、フィールドへの代入は throw します。
claude -p では mod hook は走るが、描画は出ない
“Where mods run” の表は、hook が走る場所と描画が出る場所を分けています。ターミナルと Desktop アプリの Code タブは両方とも出ます。VS Code 拡張のチャット、claude -p、Agent SDK、cloud session では hook だけ走り、pane も band も出ない。描く mod は e.surface を見て、出ない場所ではコマンドのテキスト返答に落とす設計が要ります。
mod作成の最小構成を claude -p で動かす
公式チュートリアルの first-mod をそのまま置きます。ツール呼び出しを数えてスピナー横に出し、/tally で件数を返す小さな mod。plugin.json は name / version / description / author だけの普通のマニフェスト、hooks.json は "modules": ["./register.js"] の1行が本体なので、載せるのは register.js だけにします。
// first-mod/hooks/register.js
let calls = 0
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'tally', description: 'Show how many tool calls Claude has made' })
return next(e)
})
on('tool.call', async ($, e, next) => {
calls += 1
$.ui.invalidate('ui.render')
return next(e)
})
on('command.run', { command: 'tally' }, async () => {
return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
})
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
}
$ claude -p "/tally" --plugin-dir ./first-mod --max-turns 1 < /dev/null
first-mod: Claude has made 0 tool calls since this mod loaded
3.5 秒で返り、モデルは呼ばれません。< /dev/null を付け忘れると stdin を 3 秒待つ警告が出て 6.7 秒かかります。先頭の first-mod: は Claude Code が付けるプラグイン名。hook が返した text は “Claude has made” から始まります。
register.js の4つの hook が1つの変数を共有する
session.start で $.command.register、tool.call で calls += 1 と再描画の要求、command.run は matcher { command: 'tally' } で /tally だけに反応して text を返し、ui.render は matcher { component: 'Spinner' } でスピナーの suffix を足す。モジュール先頭の let calls = 0 を4つが共有しています。Claude Code は --plugin-dir で読んだディレクトリを保存のたびにホットリロードし、そのたび register を呼び直すので calls は 0 に戻る。残したい値は $.state か $.store に置く決まりです。
claude plugin test はセッション無しで 0.12 秒
$ cd first-mod && claude plugin test
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [12.23ms]
1 pass
0 fail
Ran 1 test across 1 file. [0.12s]
テストは claude-code/testing から test を import し、on('tool.call', () => ({ result: 'ok' })) でツール実行を肩代わりしてから $.tool.call を2回撃ちます。サインインもネットワークも不要。モデルを回して採点する claude plugin eval とは別物で、こちらは mod hook の単体テストです。
claude plugin validate は何を見て、どこで止まるか
validate はソースを静的解析します。実行せずに hooks: と calls: を出せるのは、mod が外に出る手段が $ 経由しかなく、その書き方を縛っているから。ルールは “Check what Claude Code reads from your mod” に並んでいます。わざと破った mod を4本作り、1本ずつ通しました。
// const ui = $.ui と別名を付けた
register.js:4: $.ui is used as a value (a noun of $ bound, passed or read)
// for 文で on(ev, ...) と回した
register.js:4: the event name passed to on() is not a string literal
// hook の中で const on = 1 と宣言した
register.js:3: "on" is declared again (shadowed)
// on('telemetry.log', hook) を { to: 'collector' } 無しで書いた
its hooks stand on the telemetry stream "anthropic" (...), which is for the plugins
built into the CLI; name the collector on each telemetry hook:
on("telemetry.log", { to: "collector" }, hook)
上の3本には末尾に同じ一文が付きます。”$ is always spelled $.noun.event(…) at the call site, on is always on(“<event>”, hook), and next.to always next.to(e, “<tier>”)”。解析の前提をそのまま文にしたものです。
3箇所壊しても報告は1件
イベント名のタイポ tool.calls、$.ui の別名、for 文の3つを1ファイルに仕込むと、出るのは行番号が最も若い "tool.calls" is not an event の1件だけ。ソース規約の違反は1件直して再実行、の繰り返しになります。telemetry のストリーム検査だけ扱いが別で、hooks: と calls: を出し切った後に落ちました。
–json で CI に乗せる
claude plugin validate ./first-mod --json は success、manifest、contents[] を持つ JSON を返し、hooks: と calls: の2行は contents[0].notes に入り、失敗時は exit 1 で終わります。--strict は warning を error 扱いにするだけで、first-mod は warning 0 なので出力は変わりませんでした。
PreToolUse hook と mod の tool.call はどちらが先か
Claude Code は同じイベントの hook を1本のミドルウェアチェーンに並べます。外側ほど先にイベントを見て、後から結果を見る。”The order mods run in” の順序はこうです。
- 組み込みの guard
sec-default@builtin(managed settings があるマシンか、Team / Enterprise でサインインした場合だけ載る)と、組織のprependPlugins - ユーザーが入れた mod
- 組織の
appendPlugins - その他の組み込み mod
settings hook の PreToolUse がこのチェーンのどこに入るか。”Where settings hooks run in the order” によれば、managed settings の PreToolUse は最初の mod より前に走り、その段階のブロックは最終決定です。それ以外の ~/.claude/settings.json、プロジェクトの settings、プラグインの hooks.json に書いた PreToolUse は “run after the last mod calls `next`, as part of Claude Code’s own behavior”。最後の mod が next を呼んだ先で初めて動きます。
next を呼ばない mod は settings hook を飛ばす
私の ~/.claude/settings.json には、gh コマンドの前にアカウントを切り替える PreToolUse hook が入っています。ここに Bash の tool.call を { result: 'Skipped by my-mod' } で答える mod を足すと、その hook は呼ばれない。公式も “A mod that answers `tool.call` without calling `next` keeps them from running” と書いています。Bash を握る mod を入れる前に ~/.claude/settings.json の PreToolUse を grep して、何が飛ぶかを見ておきました。
tool.check は ask を飛ばし、個人環境では deny も覆す
tool.check は permission rule と PreToolUse hook が決めた後に発火し、next(e) の戻り値がその決定(allow / ask / deny)。hook はそれを別の値で返せます。覆せる範囲は permissions ドキュメントの “Extend permissions with hooks” が区切っています。
- ask rule: mod が allow を返せば確認なしで通る
- PreToolUse hook のブロック: managed settings 由来でなければ覆せる
- deny rule: managed settings があるマシンか Team / Enterprise なら既定で deny が勝つ。それ以外は “Anywhere else, the mod can approve a call that a deny rule refuses”
個人の Pro / Max で使っている限り guard は載らず、自分で入れた mod が自分の deny rule を外せる状態です。permissions の評価順序で書いた deny 優先は、mod が入ると前提が1段ずれます。
落ちた hook は飛ばされ、止めたかったコマンドが走る
hook が throw するか 10 秒を超えると、Claude Code はその hook を飛ばして次へ進む。コマンドを止める目的の mod には致命的で、”Handle a hook that fails” の記述は “Claude Code skips a hook that times out, so the held command would run”。on(...) の戻り値に .catch() を付けると、失敗時に代わりの答えを返して fail-closed にできます。同ページの例がこれです。
// "Handle a hook that fails" の例。guard は自前の hook 関数
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// next.error.kind は 'throw' か 'timeout'
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})
catch 側の制限時間は 1 秒しかありません。guard 本体で待つなら $.ui.ask のような mods API の中で待つのが条件で、自前の Promise を await した時間は Claude Code が 10 秒の側に数えます。
無人実行と開発中に当たる境界
–bare は理由付きで拒否、–safe-mode は無言でモデルへ
$ claude -p "/tally" --plugin-dir ./first-mod --bare --max-turns 1 < /dev/null
first-mod: hooks module not loaded: installed plugins that are not managed load no hooks module in this mode (--bare); built-in plugins load regardless
--bare は 0.6 秒で理由付きの拒否を stderr に出す。troubleshoot の “Refusal messages” 表にある文言そのままです。
--safe-mode は何も言いません。mod が載らないので /tally は未知のコマンドとしてモデルへ送られ、Error: Reached max turns (1) で終わるまで 14.7 秒。無人実行で safe-mode と mod のコマンドを組み合わせると、拒否ではなく1ターン分の消費になります。--plugin-dir を渡せない環境では CLAUDE_CODE_PLUGIN_DIRS=/tmp/first-mod で同じ結果(3.5 秒)でした。
Claude Code が読み込みのたびに型定義を 751KB 書き直す
first-mod/.claude-plugin/types/
├── claude-code/index.d.ts 576,306 bytes
├── claude-code-tools/index.d.ts 174,467 bytes
├── claude-code-mcp/index.d.ts 412 bytes
├── .gitignore (中身は * の1行)
└── tsconfig.json
--plugin-dir で読むたびに、Claude Code がその版のイベント・メソッド・要素を全部記した .d.ts を mod の中に書きます。先頭行は // Written by Claude Code 2.1.288.、4行目に EARLY ACCESS: this surface may change between releases without notice。GitHub の mods/types/claude-code.d.ts は 2.1.277 が書いた 507KB 版で、手元より古い。公式も “trust these files over any page, this one included, when they disagree” と書いています。.gitignore に * が入るので、mod を git 管理してもこのディレクトリは乗らない。mods API の名前空間は $.plugin から $.telemetry まで21個で、全部の一覧は reference の “Mods API methods” とこの d.ts にあります。
hook の制限時間とサイズ
reference の “Limits” から、作っていて当たるものだけ抜きます。
| 対象 | 上限 |
|---|---|
hook 1回の自前の実行時間(next と mods API の待ち時間は除く) | 10 秒 |
.catch ハンドラ | 1 秒 |
session.end の hook 全部で | 1.5 秒 |
$.process.run のタイムアウト | 既定 30 秒、最大 10 分 |
$.fs.read / $.fs.write の1ファイル | 4 MiB |
$.store の合計 | JSON で 4 MiB |
$.ui.invalidate('ui.render') の再描画 | 毎秒 10 回、ターミナルの表示中 pane は 30 回 |
claude plugin test の1テスト | 5 秒(timeoutMs で変更可) |
まとめ
- mod は
hooks/hooks.jsonのmodulesで決まる。claude plugin validateのhooks:とcalls:がその mod の全行動で、ソース規約の違反は1件ずつしか出ない - managed 以外の PreToolUse hook は最後の mod が
nextを呼んだ先で動く。nextを呼ばない mod はそれを飛ばし、tool.checkは個人環境なら deny rule も覆せる - hook の失敗は「飛ばして続行」。止める目的の mod は
.catchで fail-closed にする --bareは理由付きで拒否、--safe-modeは無言でコマンドをモデルに流す。型定義は Claude Code が読み込みごとに 751KB 書き直し、GitHub の版より手元が新しい