【Claude Code 連載 第82回】フックでエージェントの動作を制御する(agent-sdk/hooks)

スポンサーリンク
【Claude Code 連載 第82回】フックでエージェントの動作を制御する(agent-sdk/hooks) 用語解説
【Claude Code 連載 第82回】フックでエージェントの動作を制御する(agent-sdk/hooks)
この記事は約47分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

スポンサーリンク

背景

この連載では、Claude Codeの公式ドキュメントを1ページずつ読み解いていきます。公式ドキュメントは情報が網羅されている分、「結局どの機能を、どんな場面で使えばいいのか」は自分で考える必要があり、読むのに意外と時間がかかります。そこで、私が実務で使うために読み込んだ内容を「使う場面→実例コード」の順に整理して残していくことにしました。専門家の解説というより、一次情報を読んだ記録です。推測や動作を確認していない部分には、その都度そう書きます。

公式ドキュメント:https://code.claude.com/docs/ja/agent-sdk/hooks

1. 一言でいうと

フックは、エージェントの実行中に起きる出来事(ツールを呼ぶ直前、結果が返った直後、停止するときなど)に合わせて、自分のアプリのコードを差し込む仕組みです。差し込んだコードは、操作を止める・中身を書き換える・Claude に情報を渡す・記録を残す、といったことができます。

Claude Code 本体のフック(第11回)は、設定ファイルにシェルコマンドを書いて使いました。Agent SDK では、同じ仕組みをアプリのプログラムの中でコールバック関数として登録できます。返す値の形式はシェルコマンドのフックと共通です。

第80回と第81回では、「全ての呼び出しに効かせたい処理は PreToolUse フックに書く」と繰り返し案内しました。今回はそのフックの本体です。


2. どういう場面で役立つか

シーン1:触ってはいけないファイルやディレクトリを守る

.env のような秘密情報のファイルや、/etc のようなシステムディレクトリへの書き込みを、ツールが実行される前に止めます。PreToolUse フックは権限の評価順序の最初に走り、ここで拒否したものは bypassPermissions モードでも止まります(第80回)。止めた理由を Claude に伝えれば、Claude は同じ操作を繰り返さず、ユーザーに説明します。

シーン2:すべてのツール呼び出しを監査ログに残す

コンプライアンスや障害調査のために、どのツールがいつ呼ばれたかを外部のログ基盤に送ります。PostToolUse フックでツールの完了ごとに記録します。エージェントの動きを待たせたくない場合は、フックから「非同期」と返せば、記録の完了を待たずにエージェントが先へ進みます。

シーン3:エージェントの状況を Slack に流す

長い作業を任せている間、承認待ちになったことなどを Slack に知らせます。Notification フックが受け取る通知をそのまま転送します。SDK のセッションでは、canUseTool の承認待ちが約6秒続くと permission_prompt という通知が1回出ます。

不要・向かないケース

  • ユーザーに1件ずつ承認を聞く画面を作りたい:これは第81回の canUseTool の役割です。フックは全ての呼び出しに一律の規則を当てる用途に向いています。
  • ファイルのパスで対象を絞りたい:フックの matcher はツール名にしか一致しません。パスでの絞り込みはコールバックの中で行います(後述)。
  • 終了時に時間のかかる後片付けをしたい:SessionEnd のコールバックは、シャットダウン中に既定で1.5秒の持ち時間しかありません。
  • Python でセッションの開始・終了時に処理したい:SessionStart と SessionEnd は Python SDK ではコールバックとして登録できません。設定ファイルのシェルコマンドフックを使います(後述)。

3. コードと仕組みの解説

原文の fenced コードブロックは26本です。Python と TypeScript の対訳は両方載せ、次のように切り分けます。

  • 引用(20本):冒頭の .env 保護の完全な例、非同期出力、入力の書き換え、ブロックと理由の伝達、読み取り専用ツールの自動承認、複数ツールのマッチャー、サブエージェントの追跡、Slack 通知の完全な例、トラブルシューティング内の TypeScript 2本、設定ソースの指定。
  • 散文に圧縮(6本):「フックを設定する」の対訳2本(冒頭の完全な例と同じ登録方法の断片のため)、「複数のフックを登録する」の対訳2本(マッチャー無しの登録を並べただけで、「複数ツールのマッチャー」の例に同じ形が含まれるため)、「HTTP リクエスト」の対訳2本(Slack 通知の例と同じ作り(同期 HTTP をスレッドに逃がす、signal を渡す、例外を投げ直さない)のため)。

コードは原文から機械的に抜き出したもので、入れ子に由来する先頭インデントを外した以外は変えていません。

3-0. バージョン要件

フック自体に前提バージョンはありませんが、個別の挙動がバージョンで変わっています。

挙動必要バージョン
UserPromptSubmit・UserPromptExpansion のタイムアウトでプロンプトをブロックする(それ以前はクエリが error_during_execution で終了)/コールバック保留中に中断すると保留中のツール呼び出しを取り消すClaude Code v2.1.208 以降
PreToolUse のタイムアウトを「フックが応答しなかった」として Claude に伝える(それ以前は「ユーザーが拒否した」扱いで、無人セッションが入力待ちで止まった)v2.1.210 以降
フックの systemMessage がメッセージストリームに SDKInformationalMessage として出ることがあるv2.1.227 以降
Stop・SubagentStop のタイムアウトを「決定なし」として扱う(それ以前は失敗扱いで、他のフックの決定も捨てられた)v2.1.273 以降
Notification の permission_promptTypeScript Agent SDK v0.3.233 以降/Python Agent SDK v0.2.139 以降
PostToolUse の classifierContextTypeScript Agent SDK v0.3.236 以降

3-1. フックの仕組み(5段階)

原文はフックの動きを5つの段階で説明しています。

段階何が起きるか
1. イベントが発火するツールを呼ぶ直前(PreToolUse)、結果が返った(PostToolUse)、サブエージェントの開始・停止、アイドル、実行完了など
2. 登録されたフックを集めるoptions.hooks に渡したコールバックに加え、設定ソースが有効なら設定ファイルのシェルコマンドフックも集める。既定の query() オプションでは有効
3. マッチャーで絞る`”Write
4. コールバックが走るツール名・引数・セッション ID など、起きていることの詳細を受け取る
5. 決定を返すログや検証を済ませたあと、許可・ブロック・入力の書き換え・コンテキストの追加などを出力で指示する

段階2は見落としやすい点です。SDK のアプリで自分が登録したコールバックだけでなく、プロジェクトの .claude/settings.json に書かれたシェルコマンドフックも既定で動きます。設定ソースの扱いは第79回・第80回で見たとおりで、settingSources を明示的に絞れば読み込まれなくなります。

5段階をまとめた例が次のコードです。Write と Edit だけに反応する PreToolUse フックを登録し、書き込み先が .env なら permissionDecision: "deny" を返して止めます。

import asyncio
from claude_agent_sdk import (
    AssistantMessage,
    ClaudeSDKClient,
    ClaudeAgentOptions,
    HookMatcher,
    ResultMessage,
)


# ツール呼び出しの詳細を受け取るフックコールバックを定義する
async def protect_env_files(input_data, tool_use_id, context):
    # ツールの入力引数からファイルパスを抽出する
    file_path = input_data["tool_input"].get("file_path", "")
    file_name = file_path.split("/")[-1]

    # .env ファイルをターゲットにしている場合は操作をブロックする
    if file_name == ".env":
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "deny",
                "permissionDecisionReason": "Cannot modify .env files",
            }
        }

    # 空のオブジェクトを返して操作を許可する
    return {}


async def main():
    options = ClaudeAgentOptions(
        hooks={
            # PreToolUse イベントのフックを登録する
            # マッチャーは Write と Edit ツール呼び出しのみにフィルタリングする
            "PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]
        }
    )

    async with ClaudeSDKClient(options=options) as client:
        await client.query("Create a .env file with the standard local development database configuration")
        async for message in client.receive_response():
            # アシスタントとリザルトメッセージをフィルタリングする
            if isinstance(message, (AssistantMessage, ResultMessage)):
                print(message)


asyncio.run(main())
import { query, HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

// HookCallback 型でフックコールバックを定義する
const protectEnvFiles: HookCallback = async (input, toolUseID, { signal }) => {
  // 型安全性のために入力を特定のフック型にキャストする
  const preInput = input as PreToolUseHookInput;

  // tool_input をキャストしてそのプロパティにアクセスする(SDK では unknown として型付けされている)
  const toolInput = preInput.tool_input as Record<string, unknown>;
  const filePath = toolInput?.file_path as string;
  const fileName = filePath?.split("/").pop();

  // .env ファイルをターゲットにしている場合は操作をブロックする
  if (fileName === ".env") {
    return {
      hookSpecificOutput: {
        hookEventName: preInput.hook_event_name,
        permissionDecision: "deny",
        permissionDecisionReason: "Cannot modify .env files"
      }
    };
  }

  // 空のオブジェクトを返して操作を許可する
  return {};
};

for await (const message of query({
  prompt: "Create a .env file with the standard local development database configuration",
  options: {
    hooks: {
      // PreToolUse イベントのフックを登録する
      // マッチャーは Write と Edit ツール呼び出しのみにフィルタリングする
      PreToolUse: [{ matcher: "Write|Edit", hooks: [protectEnvFiles] }]
    }
  }
})) {
  // アシスタントとリザルトメッセージをフィルタリングする
  if (message.type === "assistant" || message.type === "result") {
    console.log(message);
  }
}

どちらを実行しても、Claude は .env を作ろうとし、フックがそのツール呼び出しを拒否し、最後に Claude が「.env は作れない」と説明する応答を返します。

言語による違いは次の3つです。

  • Python は ClaudeSDKClient を使い、TypeScript は query() を使っている。 第79回で見た「Python の query() は制御メソッドを持たない」とは別の話で、ここではどちらの書き方でもフックは登録できます。
  • TypeScript はキャストが必要。 コールバックの入力は共通の型で届くので、PreToolUseHookInput にキャストしてから使います。さらに tool_input は SDK 上 unknown 型なので、もう一段 Record<string, unknown> にキャストしています。
  • メッセージの絞り込み方が違う。 Python は AssistantMessage・ResultMessage の型で、TypeScript は message.type の文字列で判定しています。

登録の形は、hooks のキーがイベント名、値がマッチャーの配列です。各マッチャーは絞り込みのパターンと、実行するコールバックの配列を持ちます。「フックを設定する」節の対訳コードは、この登録部分を Bash 用に書き換えた断片なので引用を省きます。

3-2. 使えるイベント(33種)

イベントは33種類あります。Python SDK で使えるのは10種類だけで、残り23種類は TypeScript 専用です。原文の表を、用途の近いものごとに並べ替えて全て残します。

分類イベントPy発火する条件使用例
ツールPreToolUse○ツール呼び出しのリクエスト(ブロック・変更できる)危険なシェルコマンドを止める
ツールPostToolUse○ツールの実行結果全ファイル変更を監査ログに残す
ツールPostToolUseFailure○ツールの実行失敗ツールのエラーを処理・記録する
ツールPostToolBatch-ツール呼び出しの一まとまりが全て解決した。次のモデル呼び出しの前に1回まとまり全体に規約を1回だけ注入する
権限PermissionRequest○ツール呼び出しに権限の決定が必要独自の権限処理
権限PermissionDenied-オートモードがツール呼び出しを拒否した(分類器の判定が無い拒否も含む)拒否を記録する、再試行できるとモデルに伝える。判定の無い拒否では retry: true は無視される
プロンプトUserPromptSubmit○ユーザーがプロンプトを送信したプロンプトに追加のコンテキストを注入する
プロンプトUserPromptExpansion-入力したコマンドや MCP プロンプトが、Claude に届く前にプロンプトへ展開される。Claude 自身がスキルを呼ぶときは発火しないコマンドの直接呼び出しを止める、スキル入力時にコンテキストを足す
表示MessageDisplay-テキスト付きのアシスタントメッセージが完了した。メッセージごとに1回、全文付き表示テキストを編集・整形する(トランスクリプトは変えない)
停止Stop○エージェントの実行停止終了前にセッション状態を保存する
停止StopFailure-ターンが通常の停止ではなく API エラーで終わった失敗を記録する、アラートを送る
セッションSessionStart-セッションの初期化ログやテレメトリを初期化する
セッションSessionEnd-セッションの終了一時リソースを片付ける
セッションSetup-セッションの設定・メンテナンス初期化作業を実行する
サブエージェントSubagentStart○サブエージェントの初期化並列タスクの生成を追跡する
サブエージェントSubagentStop○サブエージェントの完了並列タスクの結果を集める
チーム・タスクTeammateIdle-チームメイトがアイドルになった作業を割り当て直す、通知する
チーム・タスクTaskCreated-TaskCreate ツールでタスクが作られたタスクの命名規約を強制する
チーム・タスクTaskCompleted-タスクが完了としてマークされた閉じる前にテストの合格を求める
コンテキストPreCompact○会話の圧縮リクエスト要約前に全トランスクリプトを保管する
コンテキストPostCompact-会話の圧縮が完了した生成された要約を記録する
コンテキストInstructionsLoaded-CLAUDE.md やルールファイルがコンテキストに読み込まれたどの指示ファイルが読まれたかを監査する
モデルPreModelSwitch-モデル切り替えのリクエスト。実行前(ブロックできる)特定モデルへの切り替えを止める
モデルPostModelSwitch-セッションのモデルが変わった(自動フォールバックを含む)新しいモデル向けのガイダンスを渡す
MCPElicitation-MCP サーバーが作業中にユーザー入力を求めたMCP の入力要求にプログラムで答える
MCPElicitationResult-ユーザーが MCP の入力要求に答えたサーバーに返る前に答えを変更・ブロックする
通知Notification○エージェントの状態メッセージ状態の更新を Slack や PagerDuty に送る
環境ConfigChange-設定ファイルが変わった設定を動的に読み直す
環境CwdChanged-セッション中に作業ディレクトリが変わったディレクトリごとに環境変数を読み直す
環境DirectoryAdded-セッション中に作業ディレクトリが追加された追加されたリポジトリの依存関係を入れる
環境FileChanged-監視中のファイルが変更・作成・削除されたプロジェクトファイルの変更時に設定を読み直す
環境WorktreeCreate-Git ワークツリーの作成分離された作業場所を追跡する
環境WorktreeRemove-Git ワークツリーの削除作業場所のリソースを片付ける

Python で使える10種類は、ツール(PreToolUse・PostToolUse・PostToolUseFailure)、権限(PermissionRequest)、プロンプト(UserPromptSubmit)、停止(Stop)、サブエージェント(SubagentStart・SubagentStop)、圧縮(PreCompact)、通知(Notification)です。ガードレールと監査に要るものは両言語でそろっていますが、セッションの開始・終了、モデル切り替え、MCP の入力要求、ファイル・ディレクトリの変化は TypeScript でしか扱えません。

3-3. マッチャー

matcher は、コールバックをいつ走らせるかを絞るパターンです。照合の対象はイベントによって違います。ツール系のフックではツール名、Notification では通知の種類です。規則は設定ファイルのマッチャーと同じで、完全一致と正規表現の評価のされ方やイベントごとの照合対象は、フックのリファレンスページに回されています。

オプション型既定値内容
matcherstringundefinedイベントの絞り込み対象と照合するパターン。ツール系ではツール名。組み込みツールには Bash・Read・Write・Edit・Glob・Grep・WebFetch・Agent などがある。MCP ツールは mcp__<server>__<action> の形で、<server> は mcpServers 設定で使ったキー
hooksHookCallback[]-必須。一致したときに走らせるコールバックの配列
timeoutnumberundefined秒単位のタイムアウト。省略時はイベントごとの既定値(3-9)。SDK のコールバックは command フックの既定値に従う

原文は、できるだけ matcher で対象ツールを絞るよう勧めています。マッチャーを省くと、そのイベントの全発生でコールバックが走ります。全ツール呼び出しを記録したいときは、意図的に省きます。

1つのコールバックを関連する複数のツールで共有する書き方が次の例です。3つのマッチャーの範囲が違います。

  • Write|Edit|NotebookEdit:パイプ区切りの完全一致の並び。ファイル変更系ツールだけで file_security_hook が走る
  • ^mcp__:正規表現。mcp__ で始まる名前の MCP ツールで mcp_audit_hook が走る
  • マッチャーなし:名前に関係なく全ツール呼び出しで global_logger が走る
options = ClaudeAgentOptions(
    hooks={
        "PreToolUse": [
            # Match file modification tools
            HookMatcher(matcher="Write|Edit|NotebookEdit", hooks=[file_security_hook]),
            # Match all MCP tools
            HookMatcher(matcher="^mcp__", hooks=[mcp_audit_hook]),
            # Match everything (no matcher)
            HookMatcher(hooks=[global_logger]),
        ]
    }
)
const options = {
  hooks: {
    PreToolUse: [
      // Match file modification tools
      { matcher: "Write|Edit|NotebookEdit", hooks: [fileSecurityHook] },

      // Match all MCP tools
      { matcher: "^mcp__", hooks: [mcpAuditHook] },

      // Match everything (no matcher)
      { hooks: [globalLogger] }
    ]
  }
};

3-4. コールバックの入力

コールバックは3つの引数を受け取ります。

  • 入力データ:イベントの詳細を持つ型付きオブジェクトです。形はイベントごとに違います。たとえば PreToolUseHookInput には tool_name と tool_input が、NotificationHookInput には message があります。
    • 全イベント共通のフィールドは session_id・cwd・hook_event_name です。
    • サブエージェントの中で発火したときは agent_id と agent_type が入ります。入り方が言語で違います。TypeScript では共通の基本入力にあり、全イベントで参照できます。Python では PreToolUse・PostToolUse・PostToolUseFailure・PermissionRequest では省略可能なフィールド、SubagentStart・SubagentStop では必須のフィールドです。
  • ツール使用 ID(str | None/string | undefined):同じツール呼び出しの PreToolUse と PostToolUse を結びつけるための ID です。
  • コンテキスト:TypeScript ではキャンセル用の signal(AbortSignal)を持ちます。Python では将来のために予約されているだけです。第81回の canUseTool と同じ扱いです。

3-5. コールバックの出力

コールバックが返すオブジェクトのフィールドは2種類に分かれます。

トップレベルのフィールドは全イベントで受け付けられます。

  • systemMessage:ユーザーにメッセージを見せる(モデルには渡らない。3-10 参照)
  • continue(Python では continue_):このフックの後にエージェントが実行を続けるかどうか

ただし、イベントによってはこれらを捨てたり、別の場所に届けたりします。どこに届くかはフックのリファレンスページのイベント別の節に書かれています。

hookSpecificOutput は、今の操作を制御します。中に入れるフィールドはイベントによって違います。

イベント設定できるフィールド
PreToolUsepermissionDecision("allow"・"deny"・"ask"・"defer")、permissionDecisionReason、updatedInput。"defer" を返すとクエリが終了し、後で再開できる
PostToolUseadditionalContext(ツール結果に情報を足す)、updatedToolOutput(Claude が見る前にツールの出力を差し替える。両言語の全ツールで使える)。古い updatedMCPToolOutput は MCP ツールの出力だけを差し替えるもので、非推奨
PostToolUse(TypeScript のみ)classifierContext:ツール呼び出しの結果についての短いメモを、オートモードの権限分類器に渡す

classifierContext には注意書きがあります。コールバックはアプリ自身のプロセスで走るので、分類器は、メモの中で中継されたユーザーの発言をユーザーの意図として重く見る可能性があります。長さの上限、同期のみという規則、メモに入れてはいけないものは、フックのリファレンスページに回されています。

何もしないで通すには {} を返します。 原文はこれを「変更なしで操作を許可する」と表現していますが、permissionDecision: "allow" を返すのとは意味が違います。{} は「このフックは口を出さない」という意味で、呼び出しはそのまま通常の権限評価(第80回の評価順序)へ進みます(筆者の整理。後述の 3-6 の注記「permissionDecision を省略すると通常の権限評価を通る」と第80回の評価順序から導いたもの)。

出力の形式は Claude Code のシェルコマンドフックと同じ JSON です。全フィールドとイベント別のオプションはシェルコマンドフックのリファレンスに、SDK の型定義は TypeScript・Python それぞれの SDK リファレンスにあります。

複数の決定がぶつかったときの優先順位は次のとおりです。

deny > defer > ask > allow

複数のフックや権限ルールが当てはまる場合、どれか1つでも deny を返せば、他が何を返しても操作はブロックされます。

非同期出力

既定では、エージェントはフックが返るのを待ってから進みます。ログ送信やウェブフックのように、エージェントの動きに影響しない副作用だけを行うフックなら、非同期の出力を返せます。エージェントはフックの完了を待たずにすぐ進みます。例の send_to_logging_service/sendToLoggingService は、自分で用意するログ関数の代わりです。

async def async_hook(input_data, tool_use_id, context):
    # バックグラウンドタスクを開始してから即座に返す
    asyncio.create_task(send_to_logging_service(input_data))
    return {"async_": True, "asyncTimeout": 30000}
const asyncHook: HookCallback = async (input, toolUseID, { signal }) => {
  // バックグラウンドタスクを開始してから即座に返す
  sendToLoggingService(input).catch(console.error);
  return { async: true, asyncTimeout: 30000 };
};
フィールド型内容
asynctrue非同期モードの合図。エージェントは待たずに進む。Python では予約語を避けるため async_ を使う
asyncTimeoutnumberバックグラウンド処理の任意のタイムアウト(ミリ秒単位)

非同期出力を返した時点でエージェントは先に進んでいるので、ブロック・書き換え・コンテキストの追加はできません。ログ、メトリクス、通知のような副作用専用です。

単位の違いに気をつけてください。マッチャーの timeout は秒、非同期出力の asyncTimeout はミリ秒です。例の 30000 は30秒です。

3-6. 入力を書き換える

Write ツールの呼び出しを捕まえ、file_path の先頭に /sandbox を付けて、全ての書き込みをサンドボックスのディレクトリに向け直す例です。書き換えた入力を updatedInput に入れ、permissionDecision: 'allow' で自動承認しています。

async def redirect_to_sandbox(input_data, tool_use_id, context):
    if input_data["hook_event_name"] != "PreToolUse":
        return {}

    if input_data["tool_name"] == "Write":
        original_path = input_data["tool_input"].get("file_path", "")
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "allow",
                "updatedInput": {
                    **input_data["tool_input"],
                    "file_path": f"/sandbox{original_path}",
                },
            }
        }
    return {}
const redirectToSandbox: HookCallback = async (input, toolUseID, { signal }) => {
  if (input.hook_event_name !== "PreToolUse") return {};

  const preInput = input as PreToolUseHookInput;
  const toolInput = preInput.tool_input as Record<string, unknown>;
  if (preInput.tool_name === "Write") {
    const originalPath = toolInput.file_path as string;
    return {
      hookSpecificOutput: {
        hookEventName: preInput.hook_event_name,
        permissionDecision: "allow",
        updatedInput: {
          ...toolInput,
          file_path: `/sandbox${originalPath}`
        }
      }
    };
  }
  return {};
};

updatedInput と permissionDecision の組み合わせは次のように振る舞います。

permissionDecision書き換えた入力の扱い
'allow'書き換えた入力で自動承認される
'ask'書き換えた入力をユーザーに見せて確認する
省略書き換えは適用され、通常の権限評価へ進む
'defer'updatedInput は無視される

さらに、元の tool_input を直接変更せず、常に新しいオブジェクトを返すよう原文は求めています。サンプルは Python では **input_data["tool_input"]、TypeScript では ...toolInput で複製してから file_path だけ差し替えています。

動作確認の注意もあります。macOS ではルート直下に /sandbox を作れないので、試すときはプレフィックスを ./sandbox や /tmp/sandbox のような書き込める場所にします。そのうえでファイルを書かせると、メッセージストリームに出る Write ツールの結果には、Claude が指定したパスではなくサンドボックス付きのパスが表示されます。

第81回の canUseTool でも入力を書き換えられましたが、そちらは「Claude には書き換えたことが知らされない」と明記されていました。フックでの書き換えも、Claude が指定したのと違う場所に書かれる点は同じです。どちらを使うにしても、Claude の後の説明と実際の動作がずれうることは意識しておく必要があります(筆者の指摘)。

3-7. ブロックして理由を伝える

/etc への書き込みを止め、理由をモデルとユーザーの両方に伝える例です。3つのフィールドが役割分担しています。

  • permissionDecision: 'deny':ツール呼び出しを止める
  • permissionDecisionReason:モデルに理由を伝え、同じ操作の再試行を避けさせる
  • systemMessage:ユーザーに何が起きたかを見せる
async def block_etc_writes(input_data, tool_use_id, context):
    file_path = input_data["tool_input"].get("file_path", "")

    if file_path.startswith("/etc"):
        return {
            # Top-level field: message shown to the user
            "systemMessage": "Remember: system directories like /etc are protected.",
            # hookSpecificOutput: block the operation
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "deny",
                "permissionDecisionReason": "Writing to /etc is not allowed",
            },
        }
    return {}
const blockEtcWrites: HookCallback = async (input, toolUseID, { signal }) => {
  const preInput = input as PreToolUseHookInput;
  const toolInput = preInput.tool_input as Record<string, unknown>;
  const filePath = toolInput?.file_path as string;

  if (filePath?.startsWith("/etc")) {
    return {
      // Top-level field: message shown to the user
      systemMessage: "Remember: system directories like /etc are protected.",
      // hookSpecificOutput: block the operation
      hookSpecificOutput: {
        hookEventName: preInput.hook_event_name,
        permissionDecision: "deny",
        permissionDecisionReason: "Writing to /etc is not allowed"
      }
    };
  }
  return {};
};

startswith("/etc") という判定は、/etcetera のように /etc で始まる別のパスにも一致します。実際に使うならパスを正規化してディレクトリ単位で判定する必要があります(筆者の指摘)。サンプルは仕組みを見せるためのものと読むのが妥当です。

3-8. 読み取り専用ツールを自動承認する

Read・Glob・Grep の3つに permissionDecision: 'allow' を返し、確認なしで走らせる例です。他のツールは通常の権限チェックを受けます。

async def auto_approve_read_only(input_data, tool_use_id, context):
    if input_data["hook_event_name"] != "PreToolUse":
        return {}

    read_only_tools = ["Read", "Glob", "Grep"]
    if input_data["tool_name"] in read_only_tools:
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "allow",
                "permissionDecisionReason": "Read-only tool auto-approved",
            }
        }
    return {}
const autoApproveReadOnly: HookCallback = async (input, toolUseID, { signal }) => {
  if (input.hook_event_name !== "PreToolUse") return {};

  const preInput = input as PreToolUseHookInput;
  const readOnlyTools = ["Read", "Glob", "Grep"];
  if (readOnlyTools.includes(preInput.tool_name)) {
    return {
      hookSpecificOutput: {
        hookEventName: preInput.hook_event_name,
        permissionDecision: "allow",
        permissionDecisionReason: "Read-only tool auto-approved"
      }
    };
  }
  return {};
};

ここで第80回の評価順序を思い出してください。フックの allow は、後に続く拒否ルールと確認ルールを飛ばせません。たとえば settings.json に Read のパス付き拒否ルールを書いていれば、このフックが許可してもその読み取りは止まります。フックの allow は、拒否ルールと確認ルールの評価を通過したあとで効く承認であって、ルールより強い通行証ではありません(第80回の内容との対応づけは筆者)。

3-9. 複数のフックとサブエージェントの追跡

複数のフック。 イベントが発火すると、一致したフックは全て並列で走ります。権限の決定は最も制限の強いものが採用され、deny が1つあれば他の結果に関係なくブロックされます。完了の順序は決まっていないので、「別のフックが先に走っているはず」という前提で書いてはいけません。各フックが単独で成り立つように書きます。原文の対訳は、authorization_check・input_validator・audit_logger の3つをマッチャー無しで並べて登録しているだけなので、引用は 3-3 の例に譲ります。

サブエージェントの追跡。 SubagentStop フックで、サブエージェントが作業を終えたときに概要を記録する例です。

async def subagent_tracker(input_data, tool_use_id, context):
    # Log subagent details when it finishes
    print(f"[SUBAGENT] Completed: {input_data['agent_id']}")
    print(f"  Transcript: {input_data['agent_transcript_path']}")
    print(f"  Tool use ID: {tool_use_id}")
    print(f"  Stop hook active: {input_data.get('stop_hook_active')}")
    return {}


options = ClaudeAgentOptions(
    hooks={"SubagentStop": [HookMatcher(hooks=[subagent_tracker])]}
)
import { HookCallback, SubagentStopHookInput } from "@anthropic-ai/claude-agent-sdk";

const subagentTracker: HookCallback = async (input, toolUseID, { signal }) => {
  // Cast to SubagentStopHookInput to access subagent-specific fields
  const subInput = input as SubagentStopHookInput;

  // Log subagent details when it finishes
  console.log(`[SUBAGENT] Completed: ${subInput.agent_id}`);
  console.log(`  Transcript: ${subInput.agent_transcript_path}`);
  console.log(`  Tool use ID: ${toolUseID}`);
  console.log(`  Stop hook active: ${subInput.stop_hook_active}`);
  return {};
};

const options = {
  hooks: {
    SubagentStop: [{ hooks: [subagentTracker] }]
  }
};

記録している項目は、サブエージェントの agent_id、そのトランスクリプトの場所 agent_transcript_path、ツール使用 ID、stop_hook_active です。入力の完全な型は SDK リファレンスに回されています。Python は input_data.get('stop_hook_active') と省略可能なものとして読み、TypeScript は SubagentStopHookInput にキャストして読んでいます。

3-10. Slack に通知を転送する

Notification フックでエージェントのシステム通知を受け、外部サービスに流します。SDK のセッションでこのフックが走る通知の種類は次の3つです。

通知の種類いつ出るか
permission_prompt権限リクエストが canUseTool コールバックで約6秒待たされたあとに1回(TypeScript Agent SDK v0.3.233 以降/Python Agent SDK v0.2.139 以降)
elicitation_completeユーザー入力を引き出すフローの完了時
elicitation_responseユーザー入力を引き出すフローへの応答時

idle_prompt・auth_success・elicitation_dialog などの種類もありますが、これらは SDK のセッションでは動かない対話用の画面から出るものです。SDK のアプリでは受け取れないと考えてください。

各通知には、人が読める説明の message と、任意の title が入っています。

次の例は全通知を Slack のチャンネルに転送します。Slack の受信ウェブフック URL が必要で、これは Slack のワークスペースにアプリを追加し、受信ウェブフックを有効にすると作れます。

import asyncio
import json
import urllib.request

from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher


def _send_slack_notification(message):
    """Synchronous helper that sends a message to Slack via incoming webhook."""
    data = json.dumps({"text": f"Agent status: {message}"}).encode()
    req = urllib.request.Request(
        "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    urllib.request.urlopen(req)


async def notification_handler(input_data, tool_use_id, context):
    try:
        # Run the blocking HTTP call in a thread to avoid blocking the event loop
        await asyncio.to_thread(_send_slack_notification, input_data.get("message", ""))
    except Exception as e:
        print(f"Failed to send notification: {e}")

    # Return empty object. Notification hooks don't modify agent behavior
    return {}


async def main():
    options = ClaudeAgentOptions(
        hooks={
            # Register the hook for Notification events (no matcher needed)
            "Notification": [HookMatcher(hooks=[notification_handler])],
        },
    )

    async with ClaudeSDKClient(options=options) as client:
        await client.query("Analyze this codebase")
        async for message in client.receive_response():
            print(message)


asyncio.run(main())
import { query, HookCallback, NotificationHookInput } from "@anthropic-ai/claude-agent-sdk";

// Define a hook callback that sends notifications to Slack
const notificationHandler: HookCallback = async (input, toolUseID, { signal }) => {
  // Cast to NotificationHookInput to access the message field
  const notification = input as NotificationHookInput;

  try {
    // POST the notification message to a Slack incoming webhook
    await fetch("https://hooks.slack.com/services/YOUR/WEBHOOK/URL", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: `Agent status: ${notification.message}`
      }),
      // Pass signal so the request cancels if the hook times out
      signal
    });
  } catch (error) {
    if (error instanceof Error && error.name === "AbortError") {
      console.log("Notification cancelled");
    } else {
      console.error("Failed to send notification:", error);
    }
  }

  // Return empty object. Notification hooks don't modify agent behavior
  return {};
};

// Register the hook for Notification events (no matcher needed)
for await (const message of query({
  prompt: "Analyze this codebase",
  options: {
    hooks: {
      Notification: [{ hooks: [notificationHandler] }]
    }
  }
})) {
  console.log(message);
}

通知が来ると、フックは Agent status: を先頭に付けた message を、ウェブフックの送り先チャンネルに投稿します。

外部への HTTP 送信で押さえる点は3つです。原文の「HTTP リクエストを実行する」節(PostToolUse で各ツール完了後にウェブフックを送る例)も、同じ3点で作られています。

  • エラーはフックの中で捕まえ、外に投げない。 両言語とも例外を捕まえてログに出すだけにしています。
  • Python では、ブロッキングする HTTP 呼び出しを asyncio.to_thread でスレッドに逃がす。 urllib.request は同期処理なので、そのまま呼ぶとイベントループが止まります。
  • TypeScript では fetch に signal を渡す。 フックがタイムアウトしたときにリクエストも取り消されます。取り消しは AbortError として届くので、他のエラーと分けて扱います。

第81回で紹介した PermissionRequest フック(承認待ちを外部に知らせる)と、この Notification の permission_prompt は、どちらも承認待ちを知らせる用途に使えます。前者は承認が必要になった時点で、後者は canUseTool で約6秒待たされた時点で発火します(発火条件は原文の記述どおり。使い分けは原文に書かれていません)。

3-11. タイムアウト

Claude Code は各コールバックをタイムアウト付きで走らせます。タイムアウトはマッチャーの timeout に秒で設定します。設定しない場合の既定値は次のとおりです。

イベント既定のタイムアウト
ほとんどのイベント600秒
UserPromptSubmit・PreModelSwitch・PostModelSwitch30秒
MessageDisplay10秒
SessionEndシャットダウン中に走るため、短い持ち時間(既定1.5秒)の中で動く

タイムアウトを超えたコールバックは取り消され、出力は捨てられ、セッションは止まらずに続きます。その後どうなるかはイベントによって違い、安全側に倒すもの(閉じる)と、何もなかったことにするもの(開く)に分かれます。

イベントタイムアウト時の動き倒れる側
PreToolUseツールは実行されない。Claude には「フックが時間内に応答しなかった」というツール結果が届き、ターンは続く。別の PreToolUse フックが明示的に拒否していれば、Claude にはその拒否が届く閉じる
PostToolUse・PostToolUseFailureツール結果は保持され、ターンは続く開く
UserPromptSubmit・UserPromptExpansionフック名とタイムアウトを示すメッセージでプロンプトをブロックし、セッションは続く。ポリシーの関門として使われうるので、確認されていないプロンプトは通さない閉じる
Stop・SubagentStop決定を返さなかったものとして扱い、許可されたかのように停止する。他のフックの決定は適用される開く
SessionStart出力なしとして扱い、他の SessionStart フックの出力で続く開く
PreModelSwitchモデル切り替えをブロックする。応答しないフックは切り替えを承認していない閉じる
その他(Notification・PreCompact・PostModelSwitch など)失敗を記録して続く開く

「倒れる側」の列は筆者の整理です。止めるための関門(ツール実行前、プロンプト受付、モデル切り替え)はタイムアウトで閉じ、記録や後処理のためのイベントは開く、という一貫した設計になっています。

補足が3つあります。

  • メインセッションで Stop か SessionStart のコールバックが初めてタイムアウトしたときは、メッセージストリームに SDKInformationalMessage が追加されます。セッションを動かしているアプリが応答しなかったことを示すものです。アプリが応答しない間、2回目以降は繰り返されません。
  • コールバックの保留中にクエリを中断すると、保留中のツール呼び出しは取り消されます(v2.1.208 以降)。
  • 時間が足りないコールバックには、マッチャーの timeout を大きく設定します。TypeScript では3つ目の引数の AbortSignal を使って、タイムアウト時の取り消しをきちんと処理します。

3-12. よくある問題と対処

フックが発火しない。

  • イベント名の大文字・小文字が正しいか(preToolUse ではなく PreToolUse)
  • マッチャーがツール名と正確に一致しているか
  • options.hooks の正しいイベントの下に置いているか
  • マッチャーを持つツール以外のフック(Notification・SubagentStop など)は照合対象のフィールドが違う。Stop はマッチャーを完全に無視する
  • max_turns の上限に達すると、フックが走る前にセッションが終わり、発火しないことがある

マッチャーが思ったように絞り込まない。 マッチャーが照合するのはツール名だけで、ファイルパスなどの引数は見ません。パスで絞るならコールバックの中で tool_input.file_path を確認します。

const myHook: HookCallback = async (input, toolUseID, { signal }) => {
  const preInput = input as PreToolUseHookInput;
  const toolInput = preInput.tool_input as Record<string, unknown>;
  const filePath = toolInput?.file_path as string;
  if (!filePath?.endsWith(".md")) return {}; // Skip non-markdown files
  // Process markdown files...
  return {};
};

ツールが予想外にブロックされる。

  • 全ての PreToolUse フックの戻り値に permissionDecision: 'deny' が無いか確認する
  • フックにログを入れ、返している permissionDecisionReason を確認する
  • マッチャーが広すぎないか確認する。空のマッチャーは全ツールに一致する

書き換えた入力が反映されない。

  • updatedInput がトップレベルではなく hookSpecificOutput の中にあるか確認する
return {
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "allow",
    updatedInput: { command: "new command" }
  }
};
  • updatedInput と permissionDecision: 'defer' を組み合わせていないか確認する。defer は書き換えを捨てる
  • hookSpecificOutput に hookEventName を入れ、どのイベント向けの出力かを示す

Python でセッションのフックが使えない。 SessionStart と SessionEnd は、Python SDK の HookEvent 型に含まれていないので、コールバックとしては登録できません。Python では .claude/settings.json などに書いたシェルコマンドフックとしてだけ使えます。SDK のアプリから読み込むには、設定ソースに該当するものを含めます。

options = ClaudeAgentOptions(
    setting_sources=["project"],  # Loads .claude/settings.json including hooks
)
const options = {
  settingSources: ["project"] // Loads .claude/settings.json including hooks
};

Python のコールバックとして初期化処理を走らせたいなら、client.receive_response() から届く最初のメッセージをきっかけに使います。

サブエージェントの権限確認が増える。 複数のサブエージェントを起動すると、それぞれが自分のツール呼び出しについて個別に権限を求めることがあります。PreToolUse フックで特定のツールを自動承認するか、権限ルールを設定します。サブエージェントは親の会話から権限ルールを引き継ぎます。

サブエージェントを使うとフックが無限ループする。 サブエージェントを起動する UserPromptSubmit フックは、そのサブエージェントが同じフックを発火させると無限ループになります。共有変数やセッション状態で「すでにサブエージェントの中にいるか」を追跡するか、フックをトップレベルのエージェントのセッションだけで走るように限定します。

systemMessage が出力に出ない。 systemMessage はモデルではなくユーザーに見せるためのものです。v2.1.227 以降、フックの systemMessage はメッセージストリームに SDKInformationalMessage として出ることがありますが、出るかどうかはイベント次第です。v2.1.227 より前は、ストリームにフック出力が出るのは SessionStart と Setup だけで、他のイベントの出力は includeHookEvents(Python では include_hook_events)が追加するライフサイクルイベントの中にしか現れませんでした。

  • モデルに情報を渡したいなら additionalContext を返す
  • フックの決定をアプリに確実に表示したいなら、別途ログに書くか専用の出力経路を使う

4. まとめ

  • フックは、ツール呼び出しの前後・停止・通知などの出来事にアプリのコードを差し込む仕組み。SDK ではコールバック関数として登録し、出力の形式はシェルコマンドのフックと共通。
  • 既定では、options.hooks のコールバックに加えて .claude/settings.json のシェルコマンドフックも動く。
  • イベントは33種類。Python で使えるのは10種類(ツール前後・失敗、権限要求、プロンプト送信、停止、サブエージェント開始・停止、圧縮前、通知)。
  • matcher はツール名(イベントによっては通知の種類など)にしか一致しない。パスでの絞り込みはコールバック内で行う。
  • PreToolUse は allow・deny・ask・defer と updatedInput を返せる。deny > defer > ask > allow で、1つの deny が全てに勝つ。defer は書き換えを捨てる。書き換えは新しいオブジェクトで返す。
  • {} は「口を出さない」で、通常の権限評価へ進む。フックの allow も後続の拒否・確認ルールを飛ばせない(第80回)。
  • 一致したフックは並列に走り、順序は決まっていない。各フックは単独で成り立つように書く。
  • 非同期出力は副作用専用で、止めることも書き換えることもできない。timeout は秒、asyncTimeout はミリ秒。
  • タイムアウトは既定600秒(プロンプト送信とモデル切り替えは30秒、表示は10秒、SessionEnd は1.5秒)。関門のイベントは閉じ、記録や後処理のイベントは開く。
  • systemMessage はユーザー向け、additionalContext はモデル向け。

次回予告

次回は 「カスタムツール」(agent-sdk/custom-tools) を取り上げる予定です。

今回のマッチャーでは、MCP ツールを mcp__<server>__<action> という名前で指定しました。第81回では、複数選択より複雑な入力や既存の承認システムとの連携は「カスタムツールの領域」だと紹介しました。次回は、Claude に使わせる自分専用のツールを SDK でどう定義し、どう渡すのかを見ていきます。今回のフックや前々回の権限の仕組みが、自作のツールにどう効くのかも確かめます。

※テーマは変更になる場合があります。


よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました