/agentsでカスタムサブエージェントを使った経験のある開発者で、サブエージェントの起動・完了タイミングで自動ログ・通知・テスト介入を仕込みたい人向け
Exploreや/agentsで自作したカスタムサブエージェントを本番で運用していて、子分が立ち上がるたびにログを残したい・終わるたびにSlackへ通知したい・テストが緑じゃないと子分を勝手に終わらせず修正させ続けたい、という場面で、.claude/settings.jsonのhooks欄にSubagentStartとSubagentStopをmatcher(agent typeで絞る欄)つきで登録する
SubagentStartとSubagentStopは、メインのClaude Codeが小さな子分を1人起こして仕事を投げ、その子分が仕事を終えるまでの「入り口」と「出口」に介入できる2つのフックです。中身としては、/agents で定義したカスタムサブエージェントや、組み込みの Explore Plan general-purpose が立ち上がる瞬間に SubagentStart が、その子分が処理を終える瞬間に SubagentStop が、それぞれ settings.json に書いた外部スクリプトを叩いてくれます。
使いどころとしては、「どの子分が動いたのかをログに残したい」「子分の作業が終わったら Slack に通知したい」「テストが落ちたままなら子分を勝手に終わらせず修正させたい」という、サブエージェント単位の運用フックがほぼ全部ここに集約されます。
噛み砕くと
会社で考えると、上司に当たるメインのClaudeが「ちょっとこの調査だけ頼む」と新人役のサブエージェントに仕事を渡す瞬間と、新人が「終わりました」と戻ってきた瞬間の、それぞれにベルを鳴らせる仕組みです。SubagentStart が「新人が会議室に入った瞬間のベル」、SubagentStop が「会議室から出てきた瞬間のベル」。
ベルが鳴ったときに何をやるかは自由で、ノートに「○月○日、Aさん入室」と書くだけでもいいし、出口のベルでは「報告書が雑だったらもう一回会議室に戻す」みたいな引き留めもできます。
ここがメインの Stop / StopFailure と地味に違うところで、SubagentStart / SubagentStop は「上司本人」ではなく「新人」のタイミングに反応します。両方を別々に書ければ、新人ごとの動きと上司の動きを切り分けて記録できる、というのが基本構造です。
大事な前提:matcher は「ツール名」ではなく「agent type」で絞る
このフックでいちばん引っかかるポイントは、matcher の中身です。PreToolUse や PostToolUse は Bash Read Edit のような「ツール名」で絞ります。一方、SubagentStart / SubagentStop は general-purpose Explore Plan、または /agents で自分が定義したカスタムエージェント名で絞ります。
ここで Bash や permission_prompt のような他イベント用の値を書いてしまうと、永久に1回も発火しません。「設定したのに動かない」の8割はこの取り違えです。
matcher を空、つまり "*" や "" や省略にすると、どの種類のサブエージェントが立ち上がっても全部発火します。最初は空運用にしてログだけ取り、種類が見えてきてから絞るのが安全です。
「cooking-blog」プロジェクトで、Exploreサブエージェントを観測してみる
世界の料理を紹介するブログ「cooking-blog」のプロジェクトで、コード調査用のサブエージェント Explore を走らせる場面を想定します。やりたいのは、Exploreが立ち上がった瞬間にローカルのログファイルへ1行追記し、終わった瞬間に Slack へ通知することです。
ステップ1: settings.json にフックを2つ登録する
プロジェクト直下の .claude/settings.json を開いて、hooks 欄に SubagentStart と SubagentStop を並べます。matcher は Explore 固定にしておきます。
{
"hooks": {
"SubagentStart": [
{
"matcher": "Explore",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/log-subagent-start.sh" }
]
}
],
"SubagentStop": [
{
"matcher": "Explore",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/notify-subagent-stop.sh" }
]
}
]
}
}
matcher 欄に Bash や git を入れたくなりますが、ここで入れるのはあくまで agent type です。
ステップ2: SubagentStart 側のスクリプトを書く
.claude/hooks/log-subagent-start.sh に、標準入力で渡される JSON をそのまま受け取って、ログ1行追記するだけの中身を入れます。
#!/bin/bash
INPUT=$(cat)
AGENT=$(echo "$INPUT" | jq -r '.agent_type')
SESSION=$(echo "$INPUT" | jq -r '.session_id')
echo "[$(date '+%Y-%m-%d %H:%M:%S')] START agent=$AGENT session=$SESSION" \
>> ./.claude/logs/subagent.log
標準入力で受け取る JSON には hook_event_name session_id cwd agent_type が入ってきます。agent_type を見れば「いま立ち上がったのは Explore なのか security-reviewer なのか」が判別できます。
ステップ3: SubagentStop 側のスクリプトを書く
.claude/hooks/notify-subagent-stop.sh では、Slack webhook を1回叩いて完了通知を送ります。webhook URL は SLACK_WEBHOOK_URL という名前で、パソコンが起動時に覚えている設定値の中に入れている前提です。
#!/bin/bash
INPUT=$(cat)
AGENT=$(echo "$INPUT" | jq -r '.agent_type')
curl -s -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\"subagent $AGENT finished on cooking-blog\"}" \
"$SLACK_WEBHOOK_URL"
ここで exit 0 で抜ければ、サブエージェントはそのまま正常終了します。Slackが落ちてても本筋は止めない方針です。
ステップ4: 実行権限を付けて、Claude Code 内で Explore を呼ぶ
2本のスクリプトに実行権限を付けます。
$ chmod +x ./.claude/hooks/log-subagent-start.sh
$ chmod +x ./.claude/hooks/notify-subagent-stop.sh
その上で Claude Code 上で「recipes/ 配下のディレクトリ構造をExploreで調べて」と頼みます。Explore サブエージェントが立ち上がった瞬間に subagent.log に「START agent=Explore」が記録され、調査が終わった瞬間に Slack に「subagent Explore finished on cooking-blog」が流れます。
ステップ5: ここで初心者がやりがちな勘違いを潰しておく
「メインの会話でも Slack通知が来てしまった」と思った場合、それはたぶん Stop フックの方を別途登録していて、メイン会話の終了でも発火している、というケースです。
逆に、メイン用に書いた Stop フックが、subagent の完了時にも勝手に動いて困ることがあります。これは仕様で、公式ドキュメントには次のように書かれています。
For subagents,
Stophooks are automatically converted toSubagentStopsince that is the event that fires when a subagent completes.
つまり「Stopにだけ書いた処理が、subagent終了時にも勝手に走る」のは公式仕様です。subagent側で別処理にしたければ、SubagentStop 側を明示的に書いて分岐させる必要があります。
ステップ6: SubagentStop で「テスト落ちたまま終わらせない」を仕込む
運用が進んできたら、SubagentStop 側で「テストが緑じゃないなら subagent を終わらせない」介入も入れられます。スクリプトの末尾を次のように変えます。
#!/bin/bash
INPUT=$(cat)
if ! npm test --silent > /dev/null 2>&1; then
cat <<'EOF'
{"decision":"block","reason":"npm test が落ちています。修正してから完了させてください"}
EOF
exit 0
fi
exit 0
JSON で decision: "block" と reason を返すと、subagent はそこで終了できず、reason に書いた指示が subagent 側の文脈に追加されて、修正を続けるよう促されます。これは SubagentStop だけにできる芸当で、SubagentStart 側ではどう書いても止められません。
つまり SubagentStart / SubagentStop は何をしてくれるのか
- やってくれる: サブエージェントが立ち上がる瞬間・終わる瞬間に、好きな外部スクリプトを発火させる。agent type で絞り込めるので「Exploreが起きたときだけ」みたいな限定運用ができる。SubagentStop は
decision: "block"で終了を引き留められる - やってくれない: ツール単位の介入。これは PreToolUse / PostToolUse の仕事。permission ダイアログのカスタマイズも別の話で、Notification フックが担当する。さらに SubagentStart 側からの停止もできない。exit 2 を返しても止まらず、stderr がユーザーに見えるだけで止まらない
- 意味が薄い場面: そもそも
/agentsを一切使っていないプロジェクト。メインのClaude Codeだけで完結している運用では、SubagentStart も SubagentStop も一度も発火しない
使いどころ3シナリオ(具体題材で再現)
シナリオ1: cooking-blogでExploreの動きを観測する
料理ブログを開発中、コード調査タスクは全部 Explore に投げている。けれどメイン会話のログだけ見ても「いつExploreが何回起きたのか」が見えづらい。そこで SubagentStart 側で ./.claude/logs/subagent.log に1行追記、SubagentStop 側で処理時間を記録すると、1日の終わりに「Exploreが12回起動、平均45秒」みたいな観測値が取れるようになる。matcher は Explore 固定にしておけば、他の組み込みサブエージェントに影響しない。
シナリオ2: 家計簿アプリ開発で「テストが緑じゃないと subagent を終わらせない」
家計簿アプリの開発で、機能追加用のカスタムサブエージェント feature-implementer を /agents から定義している。実装させたあと、子分が「終わりました」と戻ろうとした瞬間に npm test を走らせ、落ちていたら decision: "block" と reason: "テストが赤です。失敗ケースを直してください" を返す。subagent は終了せず、reason に書いた指示を追加文脈として受け取り、修正を続ける。これは SubagentStop だからこそ書ける介入で、PostToolUse では止められない位置にある。
シナリオ3: OSSをコピーしてきた直後に「security-reviewer 起動を全件通知」
仕事で複数のOSSを git clone でローカルにコピーして触っているチームで、セキュリティレビュー用のカスタムサブエージェント security-reviewer を共通化している。SubagentStart 側で matcher を security-reviewer に固定し、起動のたびに Slack の #sec-review チャンネルへ「誰がいつどのプロジェクトでレビューを始めたか」を流す。observable運用が一気に進んで「いつの間にかレビューされていた」がなくなる。SubagentStart は止められない代わりに、観測専用と割り切ると素直に使える。
初心者が踏みやすい落とし穴
- matcher にツール名を書いて1度も発火しない。PreToolUseと同じノリで
BashやReadを入れると、SubagentStart / SubagentStop は永久に動かない。入るのはgeneral-purposeExplorePlan、または/agentsで作ったカスタム名のみ - SubagentStartで止めようとして失敗する。exit code 2 で抜けても公式の表記は「Shows stderr to user only」で、サブエージェントはそのまま起動する。止めたい場合は SubagentStart ではなく、起動前のチェックは別の仕組み、つまり PreToolUse などで書く必要がある
- Stopにだけ書いた処理が subagent 終了時にも勝手に動く。公式仕様で
Stopは subagent 完了時に自動的にSubagentStopとして走る。subagentに走らせたくない処理を Stop に書いている人は、agent_typeが入っているかどうかで分岐させて回避する - SubagentStop の
decision: "block"を JSON にせずプレーンテキストで返してしまう。標準出力に流すのは{"decision":"block","reason":"..."}の JSON のみで、ただの文字列を書いても無視される - スクリプトに実行権限を付け忘れて静かに失敗する。
chmod +xしていない sh ファイルは、フックから呼ばれても起動せず、ログが何も出ない。「設定したのに動かない」のもう1つの定番原因 - SubagentStart で重い処理を書いて、サブエージェントの体感速度を遅らせる。SubagentStart は subagent 起動の手前で同期実行される。10秒かかる処理を書くと、Exploreが立ち上がる前に毎回10秒待たされる。重い処理は SubagentStop 側、または非同期化で逃がす
- SubagentStop の block を多用しすぎて subagent が永久ループする。テスト失敗で block、また失敗で block、を何回も繰り返すと subagent が抜け出せなくなる。block の回数上限や、特定条件以外では通す閾値を自前で持っておく
書き方
// .claude/settings.json
{
"hooks": {
"SubagentStart": [
{
"matcher": "Explore", // agent typeで絞る(ツール名ではない)
"hooks": [
{ "type": "command", "command": "./.claude/hooks/on-start.sh" }
]
}
],
"SubagentStop": [
{
"matcher": "Explore",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/on-stop.sh" }
]
}
]
}
}
やってみるとこうなる
入力
# SubagentStartスクリプトに標準入力で渡されてくるJSON
{
"session_id": "abc123",
"transcript_path": "~/.claude/projects/cooking-blog/transcript.jsonl",
"cwd": "~/projects/cooking-blog",
"hook_event_name": "SubagentStart",
"agent_type": "Explore"
}
出力例
# SubagentStop で「終わらせない」介入を返す場合の標準出力
{
"decision": "block",
"reason": "npm testが赤のままです。テストを通してから完了させてください"
}
# 何もしない場合はexit 0で抜けるだけでOK(標準出力に何も流さない)
このページに出てきた言葉
- フック
- 特定のタイミングで外部スクリプトを自動的に呼び出す仕組み
- サブエージェント(subagent)
- メインのClaude Codeが切り出して立ち上げる子分役のClaude。別文脈で動き、結果だけメインに返す
- agent type
- サブエージェントの種類名。<code>general-purpose</code> <code>Explore</code> <code>Plan</code> や、<code>/agents</code>で作ったカスタム名(例: <code>security-reviewer</code>)
- matcher
- フック定義の「どの対象のときだけ発火させるか」を絞る欄。SubagentStart / SubagentStopではagent typeを入れる
- /agents
- カスタムサブエージェントを定義・編集するスラッシュコマンド。ここで作った名前がそのままagent typeになる
- settings.json
- Claude Codeの設定ファイル。プロジェクト直下の<code>.claude/settings.json</code>にフック定義を書く
- decision: "block"
- SubagentStopが標準出力に返せる特別な値。サブエージェントの終了を引き留めて、もう一仕事させる
- webhook
- 「このURLを叩いたら通知が流れる」と決めておく仕組み。SlackやDiscordが提供している
- jq
- コマンドラインでJSONの中身を取り出す小さなツール