エージェントに権限を渡すと、たいていの場合は驚くほど上手く動きます。問題は、その「たいてい」をすり抜けた数パーセントで、取り返しのつかないことが起きることです。
私が最初に本番でエージェントを動かしたとき、一番怖かったのは「バグ」ではなく、「正しく動きすぎること」でした。送金 API も、メール送信も、データベースの DELETE も、ツール一覧に並べてしまえば Claude は必要だと判断したときに迷わず呼び出します。テストでは思い通りに動いていたフローが、実ユーザーのエッジケースで意図しない順序で実行され、気付いたときには顧客のメールボックスに誤った請求書が届いている、ということが起こり得ます。
ここではエージェントの一部の判断だけを人間に委ねる「Human-in-the-loop(HITL)」を、Claude API の標準的な tool_use / tool_result の往復に差し込むための本番設計を、動くコードとともに解説します。完全自律をあきらめるのではなく、「どこで止めて、誰に、どう見せるか」を最初に決めてから組むのが肝心です。
エージェントが「勝手に実行してしまう」前に考えるべきこと
HITL の設計を始める前に、まず「何を止めたいのか」を言語化してください。これは純粋に技術の話ではなく、事業リスクの話です。実際に私がレビューしてきたプロジェクトでは、ここを曖昧にしたまま「全ツール呼び出しに承認を付ける」ような実装をして、承認疲れで誰も中身を読まなくなる事故が起きがちです。
承認ゲートの設計軸は次の3つに集約できます。
不可逆性 : 送金、契約の締結、顧客への自動送信など「取り消せない」操作か
ブラスト半径 : 影響が一人の内部ユーザーに閉じるのか、外部顧客に波及するのか
金額・コンプライアンス閾値 : 一定金額以上、あるいは個人情報を含む場合のみゲートを入れるのか
この3軸で「Red(必ず承認)/ Yellow(条件付きで承認)/ Green(自動実行)」を分類し、ドキュメントとして残してからコードに落とし込みます。Red は絶対に止める、Yellow は閾値ロジック、Green は通常実行、という役割分担にすると、レビューする人間側も「いま自分は何を判定しているのか」が明確になります。
よくある誤解 — 「全部確認してもらえば安全」ではない
承認ゲートを付ければ付けるほど安全になる、というのは直感的には正しく見えますが、本番では逆に事故を増やします。人間は単調な承認依頼を何度も受けると、内容を読まずに承認するようになります。これは「rubber stamping」と呼ばれる古典的な問題で、航空業界や医療で繰り返し観察されてきました。
私の経験則では、1日あたり5件を超える承認依頼が常態化したチームでは、承認者の熟読率が急激に下がります。ゲートを減らす勇気と、Yellow の閾値設計が本番運用の質を決めます。最初の設計時に「このゲートを毎日何件発動させる想定なのか」を見積もり、想定を超えたら閾値を緩めるか、自動化のロジックを追加する判断が必要になります。
承認ゲートを入れる5つの場所
エージェントのライフサイクルのどこに承認ゲートを入れるかで、実装の複雑さと安全性のバランスが決まります。私は次の5つの場所を候補に挙げ、プロジェクトごとに最適な組み合わせを選んでいます。
1. ツール呼び出しの直前(最も一般的)
Claude が tool_use ブロックを返した直後、実際にツールを実行する前にゲートを入れます。入力パラメータを人間が見て、問題なければ実行、問題があれば拒否・修正のメッセージを返します。実装が最もシンプルで、個別のツールごとに「Red/Yellow/Green」を細かく制御できます。
2. ツール実行結果の返却前
ツールを実行し結果を取得したあと、その結果を Claude に返す前にチェックするパターンです。たとえば DB 検索の結果に個人情報が含まれていないかをフィルタしてから Claude に渡します。Claude がその情報を使ってさらなる判断をする前に遮断できるため、プライバシーや情報漏洩のリスクが高い用途に向いています。
3. 特定のパラメータ値に応じた動的ゲート
送金ツールで「1万円未満は自動、それ以上は承認必須」のように、ツール自体は承認不要でも特定の引数のときだけゲートを発動させます。最も運用負荷とのバランスが取れる設計です。私が新規案件で最初に提案するのは、ほぼ必ずこのパターンです。
4. セッション開始時の計画レビュー
Claude に一度「計画だけ」を立てさせ、その計画を人間がレビューしてから実行を開始します。長時間エージェントや重要な業務プロセスで有効ですが、Claude がセッションの途中で新しいツールを呼ぼうとしたときの扱いを別途設計する必要があります。
5. 異常検知時のフォールバックゲート
普段は完全自律で動き、エラーレートや予期せぬツール呼び出しパターンを検知したときだけ人間に介入させる、受動的なゲートです。監視メトリクスとの統合が前提になるため実装コストは高めですが、自動化率を落としたくないプロジェクトでは採用する価値があります。
プロジェクトの最初は「3(動的ゲート)」から始めるのが最も失敗が少ないというのが私の経験則です。1 と 2 は実装が単純ですが承認疲れを誘発しやすく、4 と 5 は設計が複雑です。あとから 1 や 5 を追加していく方が、運用データがある分だけ閾値設計の精度が上がります。
中断と再開を設計する — メッセージ履歴の往復を保存する
HITL のコア設計は「エージェントのループを途中で止めて、後で再開できるようにする」ことです。Claude API は元々ステートレスなので、再開のためには「承認待ちのスナップショット」を自前で永続化する必要があります。
最小構成のデータモデルは次のとおりです。
session_id : エージェントセッションの ID
messages : それまでの messages 配列全体(JSON)
pending_tool_use : 承認対象の tool_use ブロック(id, name, input)
status : awaiting_approval / approved / rejected / expired
created_at / expires_at : 期限管理
次のコードは、この状態機械を Claude API と接続する中核のループです。
# agent_loop.py
# 目的: ツール呼び出しの直前で承認ゲートを挟み、承認が得られたら同じ状態から再開する
# 前提: anthropic>=0.40、Python 3.11+、永続化は Redis/Postgres どちらでも可
import json
import os
from dataclasses import dataclass
from typing import Any, Callable
from anthropic import Anthropic
client = Anthropic( api_key = os.environ[ "ANTHROPIC_API_KEY" ])
MODEL = "claude-sonnet-4-5"
# Red リスト: 必ず承認を要求するツール名
RED_TOOLS = { "send_money" , "delete_user" , "send_email_to_customer" }
@dataclass
class ApprovalRequired ( Exception ):
session_id: str
tool_use_id: str
tool_name: str
tool_input: dict
def needs_approval (tool_name: str , tool_input: dict ) -> bool :
"""Red/Yellow 判定。Yellow は金額閾値で判定する例。"""
if tool_name in RED_TOOLS :
return True
if tool_name == "refund_payment" :
amount = tool_input.get( "amount_jpy" , 0 )
return amount >= 10_000 # 1万円以上は承認
return False
def run_tool (tool_name: str , tool_input: dict ) -> str :
"""実ツール実行は呼び出し側で差し替え可能にする。"""
# プロダクションでは Stripe SDK や DB クライアントを呼ぶ
return json.dumps({ "ok" : True , "tool" : tool_name, "input" : tool_input})
def step_agent (
session_id: str ,
messages: list ,
tools: list ,
store: "StateStore" ,
on_approval_required: Callable[[ApprovalRequired], None ],
) -> dict :
"""1ターン分だけ進める。承認が必要なら ApprovalRequired を投げる。"""
resp = client.messages.create(
model = MODEL ,
max_tokens = 1024 ,
tools = tools,
messages = messages,
)
# Claude の返答を履歴に追加
messages.append({ "role" : "assistant" , "content" : resp.content})
if resp.stop_reason == "end_turn" :
store.save(session_id, messages = messages, pending = None , status = "completed" )
return { "status" : "completed" , "text" : _final_text(resp)}
# tool_use ブロックを処理
tool_results = []
for block in resp.content:
if block.type \ != "tool_use":
continue
if needs_approval(block.name, block.input):
# セッションを保存して承認待ちへ
store.save(
session_id,
messages = messages,
pending = {
"tool_use_id" : block.id,
"name" : block.name,
"input" : block.input,
},
status = "awaiting_approval" ,
)
exc = ApprovalRequired(
session_id = session_id,
tool_use_id = block.id,
tool_name = block.name,
tool_input = block.input,
)
on_approval_required(exc)
raise exc
# 自動実行ツール
result = run_tool(block.name, block.input)
tool_results.append({
"type" : "tool_result" ,
"tool_use_id" : block.id,
"content" : result,
})
# tool_results をまとめて user メッセージに積む
messages.append({ "role" : "user" , "content" : tool_results})
store.save(session_id, messages = messages, pending = None , status = "running" )
return { "status" : "running" }
def _final_text (resp) -> str :
return "" .join(b.text for b in resp.content if b.type == "text" )
このループでは、ApprovalRequired 例外を投げる前に 必ず状態を保存 しているのがポイントです。例外を投げてから保存する順序にすると、外部通知の失敗などでセッションを見失います。また、Claude が同じターンで複数の tool_use を返してきた場合は、1 つでも承認が必要なものがあればターン全体を止める設計にしています。一部だけ実行して残りを止めると、後続のツールが前提としていた状態がずれることがあるからです。
承認後の再開 — 拒否された場合の扱いが設計の差になる
承認が得られたら、保存済みの messages に tool_result を追加してもう一度 step_agent を呼び出すだけで再開できます。拒否された場合は、tool_result の is_error を true にして、理由を Claude に伝えます。
# resume_agent.py
def resume_after_approval (
session_id: str ,
decision: str , # "approved" or "rejected"
note: str ,
store: "StateStore" ,
tools: list ,
) -> dict :
state = store.load(session_id)
if state[ "status" ] \ != "awaiting_approval":
raise ValueError ( f "session { session_id } is not waiting" )
pending = state[ "pending" ]
messages = state[ "messages" ]
if decision == "approved" :
result = run_tool(pending[ "name" ], pending[ "input" ])
tool_result = {
"type" : "tool_result" ,
"tool_use_id" : pending[ "tool_use_id" ],
"content" : result,
}
else :
# 拒否理由を Claude に渡し、別の手段を考えさせる
tool_result = {
"type" : "tool_result" ,
"tool_use_id" : pending[ "tool_use_id" ],
"content" : f "Human reviewer rejected this tool call. Reason: { note } " ,
"is_error" : True ,
}
messages.append({ "role" : "user" , "content" : [tool_result]})
store.save(session_id, messages = messages, pending = None , status = "running" )
return step_agent(session_id, messages, tools, store, on_approval_required =lambda e: None )
拒否時のメッセージを is_error: true で返すと、Claude は「別の方法で目的を達成する」方向に動きます。これが単なる例外で落ちる実装との大きな違いです。ただし、拒否理由が曖昧だと Claude が同じツールをもう一度呼び出しに来ることがあります。拒否コメント欄は自由記述にしつつも、オペレーター向けにテンプレート(「金額閾値超過のため」「顧客の本人確認が未完了のため」など)を用意しておくと、再要求の回数が目に見えて減ります。
Claude API で LangGraph を使ったステートフルエージェントを本番運用するガイド では、状態機械をより宣言的に書く方法を解説していますので、本番で複雑な承認フローを扱う場合はあわせて読んでみてください。
承認 UI と非同期通知を繋ぐ — Slack と メールの実装
保存された承認待ちセッションを、実際に人間に通知する部分は、ビジネス側の運用に合わせて選択します。私の経験上、社内オペレーター向けなら Slack、顧客対応なら メール+専用 UI が現実的です。
Slack インタラクティブメッセージで承認する
Slack の Block Kit と Interactive Components を使うと、承認・拒否ボタン付きのメッセージを送り、そのクリックを Webhook で受け取れます。次は承認依頼を送信する側の実装です。
# notify_slack.py
# 目的: 承認待ちセッションを Slack に投げ、返信用の action_id にセッション ID を埋め込む
import os
import requests
SLACK_WEBHOOK_URL = os.environ[ "SLACK_WEBHOOK_URL" ]
def send_approval_request (session_id: str , tool_name: str , tool_input: dict ) -> None :
"""Slack の承認依頼メッセージを投稿する。"""
text_input = " \n " .join( f "• * { k } *: ` { v } `" for k, v in tool_input.items())
payload = {
"blocks" : [
{
"type" : "section" ,
"text" : {
"type" : "mrkdwn" ,
"text" : f "*承認依頼* — エージェントが ` { tool_name } ` を実行しようとしています。" ,
},
},
{ "type" : "section" , "text" : { "type" : "mrkdwn" , "text" : text_input}},
{
"type" : "actions" ,
"elements" : [
{
"type" : "button" ,
"text" : { "type" : "plain_text" , "text" : "承認" },
"style" : "primary" ,
"action_id" : f "approve:: { session_id } " ,
},
{
"type" : "button" ,
"text" : { "type" : "plain_text" , "text" : "拒否" },
"style" : "danger" ,
"action_id" : f "reject:: { session_id } " ,
},
],
},
]
}
r = requests.post( SLACK_WEBHOOK_URL , json = payload, timeout = 5 )
r.raise_for_status()
Webhook で返ってきたボタンクリックを受け取ったら、resume_after_approval を呼ぶだけです。注意点として、Slack は 3 秒以内に HTTP 200 を返さないとボタンを「失敗」と表示するため、重い処理は非同期キュー(Celery, BullMQ, AWS SQS など)に逃がします。受信直後は即座に ACK を返し、実際のエージェント再開はバックグラウンドワーカーが担当する構成が安定します。
メール承認 — 署名付きトークンで 2 クリック完結
社外の関係者に承認を依頼する場合は、メールに承認 URL を貼るのが一番素直です。URL には HMAC 署名付きのトークンを埋め込み、リンクをクリックしただけで承認が完了する設計にします。
# email_approval.py
# 目的: セッション ID と判定を署名付きトークンに詰め、メール URL を生成する
import hmac
import hashlib
import json
import base64
import os
import time
SIGNING_KEY = os.environ[ "APPROVAL_SIGNING_KEY" ].encode()
def _sign (payload: dict ) -> str :
raw = json.dumps(payload, separators = ( "," , ":" ), sort_keys = True ).encode()
sig = hmac.new( SIGNING_KEY , raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + b "." + sig).decode()
def build_approval_url (session_id: str , decision: str , ttl_sec: int = 3600 ) -> str :
token = _sign(
{
"session_id" : session_id,
"decision" : decision,
"exp" : int (time.time()) + ttl_sec,
}
)
return f "https://example.com/approve?token= { token } "
def verify_token (token: str ) -> dict :
try :
blob = base64.urlsafe_b64decode(token.encode())
raw, sig = blob.rsplit( b "." , 1 )
expected = hmac.new( SIGNING_KEY , raw, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
raise ValueError ( "bad signature" )
data = json.loads(raw)
if data[ "exp" ] < int (time.time()):
raise ValueError ( "token expired" )
return data
except Exception as e:
raise ValueError ( f "invalid token: { e } " ) from e
この実装のポイントは、トークンに有効期限を必ず含めることと、HMAC 比較に hmac.compare_digest を使ってタイミング攻撃を避けることです。ワンクリック承認は便利ですが、メール転送で URL が漏洩するリスクがあるため、特に重要な操作では社内システムへのログイン+2FA を噛ませることを検討してください。承認リンクのクリックを受けたら、先ほどの CAS ロジックで遷移し、すでに決裁済みなら「この依頼は処理済みです」と表示するエンドポイントも用意しておくと、ユーザー体験がぐっと良くなります。
承認待ちの期限切れとタイムアウトを扱う
承認待ちセッションは「いつまでも待つ」わけにはいきません。1 時間後にはオペレーターが帰宅している、週末をまたぐ、そもそも通知を見落としている、というケースを必ず考えます。
私が採用しているデフォルトは次の 3 層構造です。
Soft timeout(15 分) : 初回通知から 15 分経過したらリマインダーを再送
Hard timeout(4 時間) : 自動で拒否扱いにし、Claude に「タイムアウトで実行できませんでした」と伝える
Escalation(1 時間) : 主担当の上位者に追加通知
次のようなバックグラウンドジョブで定期的にスキャンします。
# timeout_worker.py
# 目的: 承認待ちセッションを定期的に走査し、リマインド / エスカレーション / タイムアウトを処理する
import time
def sweep (store, slack_sender, resumer):
now = int (time.time())
for session in store.list_awaiting_approval():
age = now - session[ "created_at" ]
if age > 4 * 3600 and not session.get( "expired" ):
# 自動拒否
store.mark_expired(session[ "id" ])
resumer.resume(
session[ "id" ],
decision = "rejected" ,
note = "Approval request expired after 4 hours without response." ,
)
elif age > 3600 and not session.get( "escalated" ):
slack_sender.escalate(session)
store.mark_escalated(session[ "id" ])
elif age > 900 and not session.get( "reminded" ):
slack_sender.remind(session)
store.mark_reminded(session[ "id" ])
タイムアウトで拒否扱いにしたセッションの履歴は消さずに残しておきます。後から「なぜこの取引が実行されなかったのか」を追跡できることが、コンプライアンス上非常に重要になります。また、期限値は業務時間と密接に関係するため、営業時間外は Hard timeout を「翌営業日の朝 10 時」まで延ばすなど、カレンダー対応を入れると失敗が減ります。
本番で失敗しがちな3パターン
ここからは、私自身も実際にぶつかった、あるいはレビューで見てきた典型的な失敗パターンを3つ共有します。
パターン1: 権限エスカレーション — 承認後に別のツールを呼び出してしまう
送金 A の承認を取ったあと、Claude が再開後のターンで「ついでに」送金 B を自動実行してしまうケースです。Claude は会話の流れで文脈を広げる傾向があるため、承認された tool_use_id 以外の操作は同じターンで実行させない、というルールが必要です。
対処は単純で、resume_after_approval で tool_result を追加した直後のターンで返ってきた tool_use についても、needs_approval で厳格に再判定することです。「1 回承認したから次も安全」という暗黙の前提を排除します。実装としては、needs_approval を状態依存にせずステートレスな純関数として書くこと、セッションごとに「承認済みツール ID のセット」を持ち回らないこと、の2点を徹底します。
パターン2: 二重実行 — 承認ボタンを連打された
Slack のボタンを素早く 2 回クリックしたり、ネットワークリトライで同じ承認イベントが 2 回届くと、同じツールを 2 回実行してしまう可能性があります。送金や課金系では致命傷になります。
対策は「冪等性トークン」と「ステータス遷移ロック」の併用です。resume_after_approval は status = awaiting_approval から status = running への遷移を CAS(Compare-And-Swap)で行い、2 回目以降のリクエストは何もせず 409 を返します。Redis なら SET NX、Postgres なら UPDATE ... WHERE status = 'awaiting_approval' RETURNING id で表現できます。Slack のボタン側では、UI を即座に「処理中」に切り替え、再クリックを視覚的にも防ぐとなお良いです。
パターン3: 状態不整合 — 承認後に外部システムがタイムアウト
承認は得られたが、実ツールが外部 API のタイムアウトで失敗し、しかもその外部 API は結果的に成功していた、というパターンです。決済プロバイダが 30 秒以上かかり、クライアント側はタイムアウトでリトライしたが、サーバー側は 1 回目で完了していて二重課金、といった事故が典型です。
この対策は HITL 固有というより、分散システム全般の話になりますが、エージェントで扱う場合は特に、外部 API 呼び出しに冪等性キーを必ず付ける のが鉄則です。Stripe の Idempotency-Key ヘッダのような仕組みが提供されているならそれを使い、なければ自前で session_id + tool_use_id をキーにします。Claude API のセルフヒーリングエージェント本番パターン集 でもリトライと冪等性の話題を掘り下げているので、あわせて参照してください。
監査ログとコンプライアンスに耐える設計
HITL を入れる一番大きな副次効果は、「誰が、何を、いつ承認・拒否したか」の完全なログが自然と残ることです。これをコンプライアンス資料として使えるようにするには、少し意識して設計する必要があります。
最低限残すべきフィールドは次のとおりです。
承認対象の ツール名・入力パラメータ・Claude の推論内容 (tool_use ブロック全体)
承認者の ID とメールアドレス (Slack の user_id だけでは後から追跡しづらい)
承認者の 決裁根拠 (自由記述のコメント欄を必ず用意する)
決裁時刻(サーバー側のタイムスタンプを信頼する)
そのときの Claude のモデル名とバージョン (後日、挙動の違いを検証できるように)
リクエストに紐づく トレース ID (分散トレーシングとの接続点)
これらを追加書き込み専用(append-only)のストアに残すのが理想です。PostgreSQL なら論理的に削除・更新を禁止するルールを付け、金融系など規制の強い領域では WORM(Write Once Read Many)ストレージに書き出す運用が求められることもあります。
また、個人情報を含むツール入力は、ログに残すときにマスクするか、暗号化キーをアクセス制御下に置いてください。承認ログそのものが、レビュー対象の情報を過剰に含んでしまうと、ログ漏洩が二次被害につながります。さらに、監査人や社外レビュワーが過去の承認を俯瞰できるよう、週次で承認サマリ(件数・承認者分布・ツール別の比率)を自動生成する仕組みを初期から組み込んでおくと、後からの対応が楽になります。
観察性と運用メトリクス — 「ゲートが正しく効いているか」を見える化する
承認ゲートを入れた後は、それが本当に機能しているかを定量的に見られるようにする必要があります。よくあるのが「ゲートは実装したが、誰も数字を見ていないので、実は半年前から壊れていた」という状況です。
私が必ず計測している指標は次のとおりです。
ゲート発動率 : 全ツール呼び出しのうち、承認を要求した割合
承認 / 拒否 / タイムアウト率 : 決裁の分布
承認までの中央値(P50)と 95 パーセンタイル : オペレーターの反応速度
拒否理由の分類 : 頻出理由に応じて閾値調整やツール改修の判断材料にする
同一セッション内での再承認要求数 : 高いと Claude が拒否理由を理解できていない兆候
これらを週次でレビューすると、Yellow 閾値をいつ見直すべきか、自動化をさらに進められる領域はどこか、逆に自動化しすぎたゲートを引き上げるべきかが判断できます。
小さく始める — 既存エージェントに承認ゲートを後付けする3ステップ
すでに動いているエージェントに HITL を後付けする場合、一気に完璧を目指すと頓挫します。私が勧める段階的導入は次の 3 ステップです。
ステップ1 — Shadow モード : 承認ゲートのロジックは動かすが、実際には止めずに「もし承認を要求していたらどうなっていたか」をログに残すだけにします。1 週間運用して、承認件数の実測値を確認します。週 50 件を超えるなら閾値設計を見直します。
ステップ2 — Red のみゲートON : 最も重要な 1〜2 個のツール(例: 送金、外部向けメール送信)だけ、実際に承認を要求する状態に切り替えます。承認待ち中のセッションは保持されるので、オペレーターの慣熟期間として 1〜2 週間見ておきます。
ステップ3 — Yellow の閾値チューニング : 金額や件数の閾値を本番データで微調整します。毎週「承認しなくても良かったもの」「承認すべきだったのに自動実行したもの」を 1 件ずつレビューするだけで、閾値の精度は上がっていきます。
この 3 ステップで進めると、Shadow モードの段階で「想定の 3 倍、承認が発生しそうだった」といった事実が見えてきて、閾値設計の見直しができます。いきなり本番でゲートを ON にすると、最初の数時間で承認疲れが発生して運用が回らなくなりがちです。
Agent SDK をベースにしている場合は、Claude Agent SDK で本番マルチエージェントシステムを構築する完全ガイド で扱っているミドルウェアの仕組みを使うと、before_tool フックとして承認ゲートをきれいに差し込めます。
次の一歩
まずは現在あなたのエージェントが呼び出しているツール一覧を書き出し、不可逆性・ブラスト半径・金額閾値の3軸で Red/Yellow/Green に分類してみてください。それだけでも、本番に出す前に塞いでおくべき穴が2〜3個は見えてくるはずです。そのうえで Shadow モードから始めれば、承認疲れを起こさずに HITL を運用に乗せられます。
この記事で設計した timeout worker やエスカレーションの閾値を「SLO として定義する」発想を取り入れると、運用がぐっと楽になります。