【Claude Code 連載 第81回】承認とユーザー入力を処理する(agent-sdk/user-input)

スポンサーリンク
【Claude Code 連載 第81回】承認とユーザー入力を処理する(agent-sdk/user-input) 用語解説
【Claude Code 連載 第81回】承認とユーザー入力を処理する(agent-sdk/user-input)
この記事は約40分で読めます。
よっしー
よっしー

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

スポンサーリンク

背景

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

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

1. 一言でいうと

Agent SDK で作ったアプリが、Claude からの「この操作をしてもいいか」と「どちらにするか」という2種類の問いかけをユーザーに見せ、答えを Claude に返すための仕組みです。どちらも canUseTool というコールバック1つで受け取ります。

Claude Code を対話で使っているときは、承認ダイアログや選択肢カードがターミナルに出て、そこで答えます。SDK で自分のアプリを作る場合、その画面は自分で用意しなければなりません。このページは、その画面と Claude をつなぐ約束事を定めたものです。

前回(第80回)では、ツール呼び出しがフック・拒否ルール・権限モード・許可ルールを順に通り、どこでも決まらなかったものだけが最後に canUseTool へ届く、という評価順序を扱いました。今回はその最後のステップの中身です。


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

シーン1:社内ツールに承認画面を組み込む

社内向けの Web アプリで Claude にリポジトリの整理を任せ、ファイル削除やコマンド実行の前だけ画面上で「許可/拒否」を押してもらう、という使い方です。canUseTool の中で画面に確認を出し、押されたボタンに応じて許可か拒否を返します。拒否するときに「削除ではなくアーカイブにしてほしい」と理由を添えれば、Claude はそれを読んでやり方を変えます。

シーン2:セットアップウィザードで方針を選んでもらう

「新しいアプリの技術構成を決めて」のように答えが1つに決まらない依頼では、Claude が AskUserQuestion ツールで「クロスプラットフォームにするかネイティブにするか」のような選択肢付きの質問を出してきます。アプリはそれを選択肢カードとして表示し、選ばれた答えを返します。Claude は推測で決めずに、ユーザーの意向に沿って進めます。

シーン3:承認待ちになったことを外部に知らせる

長い作業を任せて席を離れる場合、承認待ちで止まったことに気づけません。PermissionRequest フックを使うと、Claude が承認を待ち始めた時点で Slack・メール・プッシュ通知を送れます。承認そのものは canUseTool で受け、通知はフックで出す、という分担になります。

不要・向かないケース

  • 人がいない環境で自動実行するエージェント(CI など):答える人がいないので、コールバックを待たせる意味がありません。前回扱った dontAsk モードで、リスト外の操作を確認なしに拒否する構成が向いています。
  • 全てのツール呼び出しで必ず走らせたいチェック:canUseTool は自動承認されたツールでは呼ばれません。この用途には PreToolUse フックを使うよう公式が明示しています(後述)。
  • 複数選択より複雑な入力(フォーム、多段のウィザード、既存のチケット承認システムとの連携):AskUserQuestion は1回あたり最大4問・各2〜4択です。これを超える入力はカスタムツールで作る領域です。
  • サブエージェントからの質問:AskUserQuestion は、Agent ツールで起動したサブエージェントでは現在使えません。

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

原文の fenced コードブロックは27本あります。Agent SDK 系ページの方針どおり Python と TypeScript の対訳は両方載せますが、同じ形の繰り返しが多いので次のように切り分けます。

  • 全数引用(19本):コールバックの登録、y/n 承認の完全な例、応答の3パターン(変更して承認/承認して記憶/代替案を提案)、確認質問の設定と回答の返し方、プレビュー設定、完全な例。
  • 表と散文に圧縮(8本):「承認」「拒否」タブの対訳4本(y/n の例と応答表で内容が尽くされるため)、AskUserQuestion 判定の対訳2本(完全な例に同じ分岐が含まれるため)、質問形式と応答形式の JSON 2本(引用した入力例・回答例と同じ構造の再掲のため)。

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

3-0. 先に押さえる前提

コードより先に、このページの大前提を4つ示します。

(1) 自動承認されたツールではコールバックは呼ばれない。 前回の評価順序で、許可ルールや acceptEdits・bypassPermissions によって先に決まった呼び出しは canUseTool まで届きません。allowed_tools にツール名をそのまま書いた場合、そのツールで canUseTool が呼ばれるのは、確認(ask)ルールや plan モードのように評価の流れがプロンプト側へ戻す経路を通るときだけです。全ての呼び出しに効かせたい処理は PreToolUse フックに書きます。また dontAsk モードではコールバックは呼ばれず、拒否になります。

(2) 独自の質問は差し込めない。 確認質問の問いと選択肢は Claude が作ります。アプリの役割はそれを見せて、選ばれた答えを返すことだけです。アプリ側でユーザーに別の質問をしたいなら、このフローの外で、アプリのロジックとして行います。

(3) コールバックは返るまで実行を止める。期限はない。 通常の会話では Claude がターンを終えて次のメッセージを待ちますが、canUseTool は違います。ツール呼び出しの途中で止まり、コールバックが値を返すまで無期限に待ちます。ユーザーの返事が、プロセスを動かし続けられる時間より遅くなりそうなら、PreToolUse フックで defer(後回し)の決定を返します。そうするとプロセスをいったん終了し、保存されたセッションから後で再開できます。

(4) バージョン要件が2つある。

挙動必要バージョン
許可の応答で updatedInput を省略できる(それ以前は検証エラーで拒否された)Claude Code v2.1.207 以降
Python で「承認して記憶」(updated_permissions)を使うclaude-agent-sdk 0.1.80 以降

3-1. コールバックを登録する

クエリのオプションに canUseTool(Python は can_use_tool)を渡します。コールバックは Claude がユーザー入力を必要とするたびに呼ばれ、ツール名と入力を受け取ります。

from claude_agent_sdk import ClaudeAgentOptions


async def handle_tool_request(tool_name, input_data, context):
    # ユーザーにプロンプトを表示して、許可または拒否を返す
    ...


options = ClaudeAgentOptions(can_use_tool=handle_tool_request)
async function handleToolRequest(toolName, input, options) {
  // options には { signal: AbortSignal, suggestions?: PermissionUpdate[] } が含まれます
  // ユーザーにプロンプトを表示して、許可または拒否を返す
}

const options = { canUseTool: handleToolRequest };

コールバックが呼ばれるのは次の2つの場合です。

  1. ツールに承認が必要なとき:権限ルールや権限モードで自動承認されなかったツールを使おうとしたとき。tool_name を見て、どのツール("Bash"、"Write" など)かを判断します。
  2. Claude が質問するとき:Claude が AskUserQuestion ツールを呼んだとき。tool_name == "AskUserQuestion" で見分け、承認とは別の処理に回します。オプションで tools 配列を指定するなら、そこに AskUserQuestion を入れておかないとこの経路は働きません。

コールバックが受け取る引数は3つです。

引数内容
toolName使いたいツールの名前(例:"Bash"、"Write"、"Edit")
inputツールに渡そうとしているパラメーター。中身はツールごとに違う
options(TS)/context(Python)追加情報。次回以降の確認を省くための提案 suggestions(PermissionUpdate の配列)と、キャンセル信号を含む

キャンセル信号には言語差があります。TypeScript の signal は AbortSignal として使えます。Python では信号のフィールドは将来のために予約されているだけです。Python 側の型は ToolPermissionContext として SDK リファレンスに回されています。

input の中身の代表例は次のとおりです。完全なスキーマは Python・TypeScript それぞれの SDK リファレンスにあります。

ツール入力フィールド
Bashcommand、description、timeout
Writefile_path、content
Editfile_path、old_string、new_string
Readfile_path、offset、limit

3-2. y/n で承認する完全な例

Claude に「/tmp にテストファイルを作って削除して」と頼み、操作のたびにターミナルで y/n を聞く例です。y 以外はすべて拒否として扱います。

import asyncio

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from claude_agent_sdk.types import (
    HookMatcher,
    PermissionResultAllow,
    PermissionResultDeny,
    ToolPermissionContext,
)


async def can_use_tool(
    tool_name: str, input_data: dict, context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
    # ツールリクエストを表示する
    print(f"\nTool: {tool_name}")
    if tool_name == "Bash":
        print(f"Command: {input_data.get('command')}")
        if input_data.get("description"):
            print(f"Description: {input_data.get('description')}")
    else:
        print(f"Input: {input_data}")

    # ユーザーの承認を取得する
    response = input("Allow this action? (y/n): ")

    # ユーザーの応答に基づいて許可または拒否を返す
    if response.lower() == "y":
        # 許可:ツールは元の(または変更された)入力で実行される
        return PermissionResultAllow(updated_input=input_data)
    else:
        # 拒否:ツールは実行されず、Claude はメッセージを見る
        return PermissionResultDeny(message="User denied this action")


# 必須の回避策:ダミーフックはストリームを canUseTool 用に開いたままにします
async def dummy_hook(input_data, tool_use_id, context):
    return {"continue_": True}


async def prompt_stream():
    yield {
        "type": "user",
        "message": {
            "role": "user",
            "content": "Create a test file in /tmp and then delete it",
        },
    }


async def main():
    async for message in query(
        prompt=prompt_stream(),
        options=ClaudeAgentOptions(
            can_use_tool=can_use_tool,
            hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[dummy_hook])]},
        ),
    ):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)


asyncio.run(main())
import { query } from "@anthropic-ai/claude-agent-sdk";
import * as readline from "readline";

// ターミナルでユーザー入力を求めるヘルパー
function prompt(question: string): Promise<string> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });
  return new Promise((resolve) =>
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer);
    })
  );
}

for await (const message of query({
  prompt: "Create a test file in /tmp and then delete it",
  options: {
    canUseTool: async (toolName, input) => {
      // ツールリクエストを表示する
      console.log(`\nTool: ${toolName}`);
      if (toolName === "Bash") {
        console.log(`Command: ${input.command}`);
        if (input.description) console.log(`Description: ${input.description}`);
      } else {
        console.log(`Input: ${JSON.stringify(input, null, 2)}`);
      }

      // ユーザーの承認を取得する
      const response = await prompt("Allow this action? (y/n): ");

      // ユーザーの応答に基づいて許可または拒否を返す
      if (response.toLowerCase() === "y") {
        // 許可:ツールは元の(または変更された)入力で実行される
        return { behavior: "allow", updatedInput: input };
      } else {
        // 拒否:ツールは実行されず、Claude はメッセージを見る
        return { behavior: "deny", message: "User denied this action" };
      }
    }
  }
})) {
  if ("result" in message) console.log(message.result);
}

Python 側にだけある「必須の回避策」に注意してください。 Python 版は PreToolUse に何もしないダミーフック(dummy_hook、{"continue_": True} を返すだけ)を登録しています。原文のコメントは、これが canUseTool のためにストリームを開いたままにするための必須の回避策だと説明しています。あわせて Python 版はプロンプトを文字列ではなく非同期ジェネレーター(prompt_stream())で渡しています。TypeScript 版はプロンプトを文字列で渡し、ダミーフックもありません。Python で canUseTool を使うときは、この2点をそのまま真似るのが安全です。回避策が要る理由の詳細は原文に書かれていないので、ここでは「そう書かれている」以上のことは言えません。

結果の取り出し方も違います。Python は ResultMessage かつ subtype == "success" のときに message.result を出力し、TypeScript は "result" in message で判定しています。

3-3. 応答の返し方

コールバックが返すのは「許可」か「拒否」のどちらかです。

応答PythonTypeScript
許可PermissionResultAllow(updated_input=...){ behavior: "allow", updatedInput }
拒否PermissionResultDeny(message=...){ behavior: "deny", message }

許可すると、Claude が求めた入力のままツールが実行されます。入力を書き換えたい場合は updatedInput(Python は updated_input)に変更後の値を入れます。v2.1.207 より前は、updatedInput を省いた許可は検証エラーでツール呼び出しごと拒否されていました。原文のサンプルはすべて、書き換えない場合でも元の input を明示的に渡しています。古いバージョンと混在しうる環境では、この書き方に合わせておくのが無難です(筆者の判断)。

拒否するときは理由を message に書きます。Claude はこのメッセージを読み、やり方を変えることがあります。

原文は、許可と拒否の2種類の組み合わせで6つの応答の仕方ができると整理しています。

応答の仕方何をするか返すもの
承認Claude が求めたとおりに実行させる許可+元の input
変更を加えて承認実行前に入力を書き換える(パスの無害化、制約の追加など)許可+書き換えた入力
承認して記憶提案された許可ルールを返し、次回から同じ呼び出しの確認を省く許可+updatedPermissions
拒否ツールを止め、理由を伝える拒否+理由
代替案を提案止めたうえで、望む方向へ Claude を誘導する拒否+代わりの案を書いた理由
完全にリダイレクト今のリクエストを飛ばし、まったく新しい指示を送るコールバックではなくストリーミング入力を使う

「承認」と「拒否」の対訳コードは、3-2 の y/n の例から入力表示を省いただけの形なので引用を省略します。以下では残りの3パターンのコードを見ます。サンプル中の ask_user/askUser は、アプリ独自の確認画面の代わりに置かれた仮の関数です。

変更を加えて承認

ユーザーは承認するが、実行前に中身を変えたい場合です。例は Bash コマンド中の /tmp を /tmp/sandbox に置き換えています。

async def can_use_tool(tool_name, input_data, context):
    if tool_name == "Bash":
        # ユーザーが承認しましたが、すべてのコマンドをサンドボックスにスコープします
        sandboxed_input = {**input_data}
        sandboxed_input["command"] = input_data["command"].replace(
            "/tmp", "/tmp/sandbox"
        )
        return PermissionResultAllow(updated_input=sandboxed_input)
    return PermissionResultAllow(updated_input=input_data)
canUseTool: async (toolName, input) => {
  if (toolName === "Bash") {
    // ユーザーが承認しましたが、すべてのコマンドをサンドボックスにスコープします
    const sandboxedInput = {
      ...input,
      command: input.command.replace("/tmp", "/tmp/sandbox")
    };
    return { behavior: "allow", updatedInput: sandboxedInput };
  }
  return { behavior: "allow", updatedInput: input };
};

原文は、Claude は結果を見るが、入力が変えられたことは知らされないと明記しています。Claude は自分が指定したパスで実行されたと思ったまま次へ進むので、結果の説明が実際の動作とずれる可能性があります。書き換えは小さく、結果から見て矛盾が出ない範囲にとどめるのがよい、というのが筆者の考えです。

言語ごとの挙動の違いも1つあります(原文には書かれていない、言語仕様による筆者の指摘です)。Python の str.replace は一致した箇所をすべて置き換えますが、JavaScript の String.prototype.replace に文字列を渡した場合は最初の1か所だけを置き換えます。/tmp が2回出るコマンドでは、2つのサンプルの結果が違います。また、文字列置換でコマンドを書き換える方法は、サンドボックスの境界として頼れるものではありません。このサンプルは「入力を書き換えられる」ことを示す例として読むのが妥当です。

承認して記憶

「今後この種の呼び出しは聞かないでほしい」場合です。3つ目の引数の suggestions には、すぐ使える PermissionUpdate が入っています。その中から選んだものを updatedPermissions で返すと適用されます。宛先(destination)が localSettings の提案を返すと、ルールが .claude/settings.local.json に書き込まれ、以後のセッションでも一致する呼び出しは確認なしで通ります。

async def can_use_tool(tool_name, input_data, context):
    choice = await ask_user(f"Allow {tool_name}?", ["once", "always", "no"])

    if choice == "always":
        persist = [
            s for s in context.suggestions if s.destination == "localSettings"
        ]
        return PermissionResultAllow(
            updated_input=input_data, updated_permissions=persist
        )
    if choice == "once":
        return PermissionResultAllow(updated_input=input_data)
    return PermissionResultDeny(message="User declined")
canUseTool: async (toolName, input, { suggestions = [] }) => {
  const choice = await askUser(`Allow ${toolName}?`, ["once", "always", "no"]);

  if (choice === "always") {
    const persist = suggestions.filter(
      (s) => s.destination === "localSettings"
    );
    return {
      behavior: "allow",
      updatedInput: input,
      updatedPermissions: persist
    };
  }
  if (choice === "once") {
    return { behavior: "allow", updatedInput: input };
  }
  return { behavior: "deny", message: "User declined" };
};

選択肢は「once(今回だけ)」「always(常に)」「no(拒否)」の3つです。「always」のときだけ localSettings 宛ての提案に絞って返しています。TypeScript は引数の分割代入で { suggestions = [] } と既定値を与え、提案が無い場合にも備えています。Python は context.suggestions を直接読んでいます。

ここで書き込まれたルールは、前回扱った評価順序の「許可ルール」として効くはずです。そうであれば次回以降、そのツール呼び出しは**canUseTool に届かなくなります**(前回の評価順序からの筆者の推論。原文はこの帰結を明記していません)。「記憶」は、承認画面を1つ減らす操作であると同時に、アプリの承認処理を通らない経路を1つ増やす操作でもあります。

代替案を提案

ユーザーはその操作を望んでいないが、別の案がある場合です。拒否の理由の中に案を書きます。

async def can_use_tool(tool_name, input_data, context):
    if tool_name == "Bash" and "rm" in input_data.get("command", ""):
        # ユーザーは削除を望んでいません。代わりにアーカイブに圧縮することを提案します
        return PermissionResultDeny(
            message="User doesn't want to delete files. They asked if you could compress them into an archive instead."
        )
    return PermissionResultAllow(updated_input=input_data)
canUseTool: async (toolName, input) => {
  if (toolName === "Bash" && input.command.includes("rm")) {
    // ユーザーは削除を望んでいません。代わりにアーカイブに圧縮することを提案します
    return {
      behavior: "deny",
      message:
        "User doesn't want to delete files. They asked if you could compress them into an archive instead."
    };
  }
  return { behavior: "allow", updatedInput: input };
};

例では、rm を含むコマンドを止め、「削除ではなくアーカイブに圧縮してほしい」と伝えています。理由の文面は英語のまま Claude に届きます。Claude はそれを読んで、どう進めるかを決めます。

判定は "rm" という文字列が含まれるかどうかだけです。rm を含む別の単語(たとえば format)にも一致してしまうので、実際に使うならコマンドを分解して判定する必要があります(筆者の指摘)。

完全にリダイレクト

単なる軌道修正ではなく方向をまるごと変えたい場合は、コールバックではなくストリーミング入力で新しい指示を直接送ります。今のツールリクエストは飛ばされ、Claude は新しい指示に従います。原文にコード例はありません。

3-4. 確認質問(AskUserQuestion)を処理する

Claude はやり方が複数ありうる作業で方向づけが欲しいとき、AskUserQuestion ツールを呼びます。これが toolName が AskUserQuestion の canUseTool 呼び出しになり、入力に選択肢付きの質問が入っています。

原文は、確認質問が特に plan モードでよく出ると書いています。Claude はコードベースを調べ、計画を出す前に質問をします。変更の前に要件を集めたい対話型の使い方に、プランモードは向いています。

処理の手順は5つです。

手順1:コールバックを渡し、ツール一覧に AskUserQuestion を入れる。 既定では AskUserQuestion は使えます。ただし tools 配列で Claude が使えるツールを絞る場合(例:Read・Glob・Grep だけの読み取り専用エージェント)は、その配列に AskUserQuestion を加えないと、Claude は質問できなくなります。

async for message in query(
    prompt="Analyze this codebase",
    options=ClaudeAgentOptions(
        # ツールリストに AskUserQuestion を含める
        tools=["Read", "Glob", "Grep", "AskUserQuestion"],
        can_use_tool=can_use_tool,
    ),
):
    print(message)
for await (const message of query({
  prompt: "Analyze this codebase",
  options: {
    // ツールリストに AskUserQuestion を含める
    tools: ["Read", "Glob", "Grep", "AskUserQuestion"],
    canUseTool: async (toolName, input) => {
      // ここで確認質問を処理する
    }
  }
})) {
  console.log(message);
}

手順2:AskUserQuestion を見分ける。 コールバックの中で toolName が "AskUserQuestion" かどうかを調べ、質問用の処理に回します。それ以外のツールは通常の承認処理に回します。この分岐は後述の完全な例にそのまま含まれているので、ここでは引用を省きます。

手順3:質問の入力を読む。 入力の questions 配列に質問が入っています。

{
  "questions": [
    {
      "question": "How should I format the output?",
      "header": "Format",
      "options": [
        { "label": "Summary", "description": "Brief overview" },
        { "label": "Detailed", "description": "Full explanation" }
      ],
      "multiSelect": false
    },
    {
      "question": "Which sections should I include?",
      "header": "Sections",
      "options": [
        { "label": "Introduction", "description": "Opening context" },
        { "label": "Conclusion", "description": "Final summary" }
      ],
      "multiSelect": true
    }
  ]
}

各質問のフィールドは次のとおりです。

フィールド内容
question表示する質問文
header質問の短いラベル(最大12文字)
options2〜4個の選択肢。各選択肢に label と description がある。TypeScript ではオプションで preview も付く(後述)
multiSelecttrue なら複数選択できる

手順4:ユーザーから答えを集める。 見せ方はアプリ次第です。ターミナルのプロンプトでも、Web フォームでも、モバイルのダイアログでもかまいません。

手順5:答えを Claude に返す。 許可の応答の updatedInput に、元の questions と answers を入れて返します。answers は、キーが質問文(question)、値が選ばれた選択肢の label のオブジェクトです。

return PermissionResultAllow(
    updated_input={
        "questions": input_data.get("questions", []),
        "answers": {
            "How should I format the output?": "Summary",
            "Which sections should I include?": ["Introduction", "Conclusion"],
        },
    }
)
return {
  behavior: "allow",
  updatedInput: {
    questions: input.questions,
    answers: {
      "How should I format the output?": "Summary",
      "Which sections should I include?": "Introduction, Conclusion"
    }
  }
};

複数選択の答えは、ラベルの配列でも、", " でつないだ文字列でもかまいません。2つのサンプルは、この2通りをそれぞれ使っています。Python は ["Introduction", "Conclusion"] という配列、TypeScript は "Introduction, Conclusion" という文字列です。

応答に入れられるフィールドを整理すると次のとおりです。

フィールド内容
questions元の質問配列をそのまま戻す(ツールの処理に必須)
answersキーが質問文、値が選ばれたラベルのオブジェクト
response質問に個別に答える代わりにユーザーが入力した、自由形式の返信(任意)

response の使いどころは限られています。ユーザーが質問カードを閉じて、特定の質問への答えではない全体的な返信を書ける画面の場合だけに設定します。response を設定すると、Claude には質問ごとの答えの一覧ではなく「ユーザーが応答しました:…」という形で届きます。質問ごとの自由記述(「その他」欄など)は response ではなく、answers[質問文] に入れます。

自由記述を受け付ける

Claude の選択肢にユーザーの望む答えが無いこともあります。その場合は次のようにします。

  • Claude の選択肢の後ろに「その他」を追加し、文字入力を受け付ける
  • 答えの値には、「その他」という語ではなく、ユーザーが入力した文章そのものを入れる

選択肢のプレビュー(TypeScript のみ)

toolConfig.askUserQuestion.previewFormat を設定すると、各選択肢に preview フィールドが付き、ラベルと一緒に見た目の試作を表示できます。

previewFormatpreview に入るもの
未設定(既定)フィールド自体が無い。Claude はプレビューを作らない
"markdown"ASCII アートとコードブロック
"html"スタイル付きの <div> 断片。<script>・<style>・<!DOCTYPE> は、コールバックが呼ばれる前に SDK が拒否する

形式の指定はセッション内の全ての質問に効きます。ただし Claude がプレビューを付けるのは、見た目の比較が役立つ選択肢(レイアウトや配色など)だけです。はい/いいえの確認や文字だけの選択では付けません。したがって表示する前に preview が undefined でないかを確認する必要があります。

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Help me choose a card layout",
  options: {
    toolConfig: {
      askUserQuestion: { previewFormat: "html" }
    },
    canUseTool: async (toolName, input) => {
      // input.questions[].options[].preview は HTML 文字列または undefined です
      return { behavior: "allow", updatedInput: input };
    }
  }
})) {
  // ...
}

HTML プレビューが付いた選択肢は次のような形で届きます。

{
  "label": "Compact",
  "description": "Title and metric value only",
  "preview": "<div style=\"padding:12px;border:1px solid #ddd;border-radius:8px\"><div style=\"font-size:12px;color:#666\">Active users</div><div style=\"font-size:28px;font-weight:600\">1,284</div></div>"
}

HTML 形式ではスクリプトやスタイル要素は SDK の段階で拒否されますが、style 属性によるインラインのスタイルは例のとおり含まれます。受け取った HTML をアプリの画面に埋め込むときの扱いはアプリ側の責任です(筆者の指摘)。

3-5. 完全な例:技術構成を決める質問に答える

「新しいモバイルアプリの技術構成を決めたい」と頼み、Claude の確認質問にターミナルで答える例です。処理は次の5段階です。

  1. 振り分ける:canUseTool がツール名を見て、"AskUserQuestion" なら質問専用の処理に回す
  2. 質問を表示する:questions 配列を順に回り、番号付きの選択肢として表示する
  3. 入力を集める:ユーザーは番号を入れるか、自由な文章(例:「jquery」「i don’t know」)をそのまま入れる
  4. 答えを対応づける:入力が番号なら選択肢のラベルを、そうでなければ入力された文章をそのまま使う
  5. Claude に返す:元の questions 配列と answers の両方を入れて返す

TypeScript 版は ask.ts として保存して npx tsx ask.ts で、Python 版は ask.py として保存して python ask.py で実行します。

import asyncio

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from claude_agent_sdk.types import HookMatcher, PermissionResultAllow


def parse_response(response: str, options: list) -> str:
    """ユーザー入力をオプション番号または自由テキストとして解析します。"""
    try:
        indices = [int(s.strip()) - 1 for s in response.split(",")]
        labels = [options[i]["label"] for i in indices if 0 <= i < len(options)]
        return ", ".join(labels) if labels else response
    except ValueError:
        return response


async def handle_ask_user_question(input_data: dict) -> PermissionResultAllow:
    """Claude の質問を表示してユーザーの回答を収集します。"""
    answers = {}

    for q in input_data.get("questions", []):
        print(f"\n{q['header']}: {q['question']}")

        options = q["options"]
        for i, opt in enumerate(options):
            print(f"  {i + 1}. {opt['label']} - {opt['description']}")
        if q.get("multiSelect"):
            print("  (Enter numbers separated by commas, or type your own answer)")
        else:
            print("  (Enter a number, or type your own answer)")

        response = input("Your choice: ").strip()
        answers[q["question"]] = parse_response(response, options)

    return PermissionResultAllow(
        updated_input={
            "questions": input_data.get("questions", []),
            "answers": answers,
        }
    )


async def can_use_tool(
    tool_name: str, input_data: dict, context
) -> PermissionResultAllow:
    # AskUserQuestion を質問ハンドラーにルーティングする
    if tool_name == "AskUserQuestion":
        return await handle_ask_user_question(input_data)
    # この例では他のツールを自動承認する
    return PermissionResultAllow(updated_input=input_data)


async def prompt_stream():
    yield {
        "type": "user",
        "message": {
            "role": "user",
            "content": "Help me decide on the tech stack for a new mobile app",
        },
    }


# 必須の回避策:ダミーフックはストリームを canUseTool 用に開いたままにします
async def dummy_hook(input_data, tool_use_id, context):
    return {"continue_": True}


async def main():
    async for message in query(
        prompt=prompt_stream(),
        options=ClaudeAgentOptions(
            can_use_tool=can_use_tool,
            hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[dummy_hook])]},
        ),
    ):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)


asyncio.run(main())
import { query } from "@anthropic-ai/claude-agent-sdk";
import * as readline from "readline/promises";

// ターミナルでユーザー入力を求めるヘルパー
async function prompt(question: string): Promise<string> {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const answer = await rl.question(question);
  rl.close();
  return answer;
}

// ユーザー入力をオプション番号または自由テキストとして解析する
function parseResponse(response: string, options: any[]): string {
  const indices = response.split(",").map((s) => parseInt(s.trim()) - 1);
  const labels = indices
    .filter((i) => !isNaN(i) && i >= 0 && i < options.length)
    .map((i) => options[i].label);
  return labels.length > 0 ? labels.join(", ") : response;
}

// Claude の質問を表示してユーザーの回答を収集する
async function handleAskUserQuestion(input: any) {
  const answers: Record<string, string> = {};

  for (const q of input.questions) {
    console.log(`\n${q.header}: ${q.question}`);

    const options = q.options;
    options.forEach((opt: any, i: number) => {
      console.log(`  ${i + 1}. ${opt.label} - ${opt.description}`);
    });
    if (q.multiSelect) {
      console.log("  (Enter numbers separated by commas, or type your own answer)");
    } else {
      console.log("  (Enter a number, or type your own answer)");
    }

    const response = (await prompt("Your choice: ")).trim();
    answers[q.question] = parseResponse(response, options);
  }

  // Claude に回答を返す(ツール処理に元の質問を含める必須)
  return {
    behavior: "allow",
    updatedInput: { questions: input.questions, answers }
  };
}

async function main() {
  for await (const message of query({
    prompt: "Help me decide on the tech stack for a new mobile app",
    options: {
      canUseTool: async (toolName, input) => {
        // AskUserQuestion を質問ハンドラーにルーティングする
        if (toolName === "AskUserQuestion") {
          return handleAskUserQuestion(input);
        }
        // この例では他のツールを自動承認する
        return { behavior: "allow", updatedInput: input };
      }
    }
  })) {
    if ("result" in message) console.log(message.result);
  }
}

main();

読むときの注意が3つあります。

  • 質問以外のツールは無条件に許可している。 コメントにあるとおり、これは「この例では」の簡略化です。実際のアプリでは、ここに 3-2・3-3 の承認処理が入ります。
  • Python 版にはここでもダミーフックとジェネレーター形式のプロンプトがある。 3-2 と同じ「必須の回避策」です。
  • 複数選択の答えは両言語とも ", " でつないだ文字列にしている。 parse_response/parseResponse が番号をラベルに変換し、つないで返します。3-4 の手順5のサンプルとは形が違いますが、どちらも許される形式です。

3-6. 制限事項

  • サブエージェント:Agent ツールで起動したサブエージェントでは、AskUserQuestion は現在使えません。サブエージェントに作業を任せる構成では、確認質問はメインのエージェントでしか出ないことになります。
  • 質問の数:AskUserQuestion 1回あたり、質問は1〜4個、各質問の選択肢は2〜4個です。

3-7. ほかの入力手段との使い分け

canUseTool と AskUserQuestion でほとんどの承認と確認はまかなえますが、SDK にはほかにも入力の手段があります。

手段向いている用途
ストリーミング入力作業の途中で中断する・方向を変える/Claude に聞かれる前に情報を足す/長い作業の間もユーザーと会話できるチャット画面を作る
カスタムツール複数選択を超えるフォーム・ウィザード・多段の手順/既存のチケット・ワークフロー・承認基盤との連携/コードレビュー画面やデプロイのチェックリストのような業務固有のやり取り

ストリーミング入力は、承認の節目だけでなく実行中ずっとユーザーとやり取りする会話型の画面に向いています。カスタムツールはやり取りを完全に制御できる代わりに、canUseTool を使うより実装の手間がかかります。


4. まとめ

  • Claude がユーザーに尋ねるのは承認が必要なときと確認質問があるときの2つで、どちらも canUseTool で受ける。コールバックが返るまで実行は無期限に止まる。長く待つなら PreToolUse フックの defer でプロセスを終了し、後で再開する。
  • 自動承認されたツールではコールバックは呼ばれない。 全呼び出しに効かせたい処理は PreToolUse フックに書く。承認待ちの通知は PermissionRequest フックで出せる。
  • 応答は許可か拒否の2種類。その組み合わせで「そのまま承認」「書き換えて承認」「承認して記憶」「拒否」「代替案を提案」ができる。方向転換はストリーミング入力で行う。
  • 書き換えは Claude に知らされない。 「記憶」は .claude/settings.local.json に許可ルールを書き、以後その呼び出しは canUseTool を通らなくなる。
  • Python で canUseTool を使うときは、ダミーの PreToolUse フックとジェネレーター形式のプロンプトという回避策が必要。
  • 確認質問の答えは questions をそのまま戻し、answers に「質問文→選んだラベル」を入れる。複数選択は配列でも ", " 区切りの文字列でもよい。自由記述は answers に入れ、response はカードを閉じて全体に返信する画面のときだけ使う。
  • tools を絞るなら AskUserQuestion を入れる。サブエージェントでは使えない。1回あたり1〜4問・各2〜4択。
  • TypeScript では選択肢に Markdown か HTML のプレビューを付けられる。付かない選択肢もあるので undefined の確認が要る。

次回予告

次回は 「フックで実行を制御する」(agent-sdk/hooks) を取り上げる予定です。

今回と前回で、「全ての呼び出しに効かせたい処理は PreToolUse フックへ」という案内が何度も出てきました。承認待ちを外部に知らせる PermissionRequest フック、長い待ちを切り上げて後で再開する defer も、フックの機能です。次回は、そのフックを SDK のコードからどう登録し、何を返せば呼び出しを止めたり書き換えたりできるのかを見ていきます。

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


よっしー
よっしー

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

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

コメント

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