Notebook
MastraのAgent Approvalでツール実行前に人間の承認を挟む
MastraのAgent Approvalを使って、読み取り系ツールは自動実行し、メール送信のような副作用のあるツールだけ承認後に実行する流れをHono APIから試します。
Guide
目次
Mastra の Agent Approval を試したので、Hono から API として呼び出す形で実装を整理します。
Agent に tool を持たせると、顧客情報の検索やメール送信のような処理を自然言語から実行できます。ただし、すべての tool を自動実行したいわけではありません。検索のような読み取り処理はLLMの判断で自動実行しても良いと判断することも多いです。
一方でメール送信やデータ更新のように外部へ影響する処理は、人間の確認を挟みたいことがあります。
Mastra では、tool に requireApproval: true を指定すると、その tool の実行直前で Agent を一時停止できます。
公式ドキュメントでは Agent Approval として説明されています。
前提
今回は Hono と連携して API から実行できるようにしています。
試す構成は以下です。
| tool | 役割 | 承認 |
|---|---|---|
lookup-customer | customer ID から顧客情報を取得する | 不要 |
send-email | 顧客にメールを送信する | 必要 |
読み取り系の lookup-customer はそのまま実行し、副作用のある send-email だけ承認対象にします。
Agent を作成する
まず顧客情報を取得する tool を作成します。これは読み取り専用なので、承認は不要です。
import { Agent } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import z from 'zod';
const mockCustomers = {
'customer-123': {
customerId: 'customer-123',
name: 'Yamada Taro',
email: 'yamada@example.com',
plan: 'Business',
renewalDate: '2026-08-20',
accountManager: 'Sato',
},
'customer-456': {
customerId: 'customer-456',
name: 'Suzuki Hanako',
email: 'suzuki@example.com',
plan: 'Enterprise',
renewalDate: '2026-09-15',
accountManager: 'Tanaka',
},
} as const;
const lookupCustomerTool = createTool({
id: 'lookup-customer',
description: 'Look up customer profile data by customer ID. This read-only tool does not require approval.',
inputSchema: z.object({
customerId: z.enum(['customer-123', 'customer-456']),
}),
outputSchema: z.object({
customerId: z.string(),
name: z.string(),
email: z.string().email(),
plan: z.string(),
renewalDate: z.string(),
accountManager: z.string(),
}),
execute: async ({ customerId }) => {
const customer = mockCustomers[customerId];
console.log(`lookup-customer result:\n${JSON.stringify(customer, null, 2)}`);
return customer;
},
});続いて、メール送信用の tool を作成します。ここで requireApproval: true を指定します。
const sendEmailTool = createTool({
id: 'send-email',
description: 'Send a customer email after human approval.',
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
priority: z.enum(['low', 'normal', 'high']).default('normal'),
}),
outputSchema: z.object({
sent: z.boolean(),
messageId: z.string(),
to: z.string(),
subject: z.string(),
body: z.string(),
priority: z.enum(['low', 'normal', 'high']),
}),
requireApproval: true,
execute: async ({ to, subject, body, priority }) => {
return {
sent: true,
messageId: `mock-email-${Date.now()}`,
to,
subject,
body,
priority,
};
},
});最後に Agent に2つの tool を渡します。
export const toolApprovalAgent = new Agent({
id: 'tool-approval-agent',
name: 'Tool Approval Agent',
instructions: `あなたはカスタマーサポート担当のアシスタントです。
ユーザーが顧客情報を確認し、顧客向けメールを送信できるように支援してください。
ユーザーがメール送信を依頼した場合:
- ユーザーが customer ID を指定している場合は、まず lookup-customer tool を使って、宛先の名前、メールアドレス、契約プラン、更新日、担当者を取得してください。
- 取得した顧客情報とユーザーの依頼内容をもとに、簡潔で丁寧な件名と本文を作成してください。
- 解決した宛先メールアドレス、件名、本文、優先度を指定して send-email tool を使ってください。
- send-email tool が成功するまで、メールを送信済みだと主張しないでください。
- customer ID、宛先メールアドレス、メールの目的が不足している場合は、送信用 tool を使う前に短く確認してください。
デモ用の customer ID は customer-123 と customer-456 です。`,
model: 'openai/gpt-5.6-luna',
tools: { lookupCustomerTool, sendEmailTool },
});ポイントは、承認が必要な tool 側にだけ requireApproval: true を付けることです。これで Agent は lookup-customer を通常通り実行し、send-email を呼び出す直前で止まるようになります。
Hono から呼び出す
generate() で Agent を呼び出す API と、承認または拒否する API を用意します。
Mastra では stream を使うこともできますが、今回は Agent Approval の流れを追いやすくするために、レスポンス全体をまとめて受け取れる generate() を使っています。
const formatGenerateResult = (result: {
text: string
finishReason?: string
runId?: string
suspendPayload?: unknown
toolCalls?: unknown
toolResults?: unknown
}) => {
return {
text: result.text,
finishReason: result.finishReason,
runId: result.runId,
suspendPayload: result.suspendPayload,
toolCalls: result.toolCalls,
toolResults: result.toolResults,
}
}
app.post('/tool-approval-agent/generate', async c => {
const { message } = await c.req.json<{ message?: unknown }>()
if (typeof message !== 'string' || message.trim().length === 0) {
return c.json({ error: 'message is required' }, 400)
}
const result = await toolApprovalAgent.generate(message)
return c.json(formatGenerateResult(result))
})承認または拒否する API では、最初の generate() で返ってきた runId と toolCallId を使います。
app.post('/tool-approval-agent/generate/approvals', async c => {
const { runId, toolCallId, approved } = await c.req.json<{
runId?: unknown
toolCallId?: unknown
approved?: unknown
}>()
if (typeof runId !== 'string' || runId.trim().length === 0) {
return c.json({ error: 'runId is required' }, 400)
}
if (toolCallId !== undefined && typeof toolCallId !== 'string') {
return c.json({ error: 'toolCallId must be a string when provided' }, 400)
}
if (typeof approved !== 'boolean') {
return c.json({ error: 'approved must be a boolean' }, 400)
}
const options = {
runId,
...(toolCallId ? { toolCallId } : {}),
}
const result = approved
? await toolApprovalAgent.approveToolCallGenerate(options)
: await toolApprovalAgent.declineToolCallGenerate(options)
return c.json(formatGenerateResult(result))
})generate() を使う場合は、承認時に approveToolCallGenerate()、拒否時に declineToolCallGenerate() を呼び出します。
curl で実行する
まず、メール送信を依頼します。
$ curl -s -X POST http://localhost:3000/tool-approval-agent/generate \
-H "Content-Type: application/json" \
-d '{"message":"customer-123 のカスタマーに、明日の打ち合わせを10:00から11:00 に変更したいとメールして。優先度は high。"}'send-email tool が承認対象なので、レスポンスは finishReason: "suspended" になります。
{
"text": "",
"finishReason": "suspended",
"runId": "5c8ce72c-53f1-498c-b7bf-d63d775e7300",
"suspendPayload": {
"toolCallId": "call_Hw3nAhAXRVn6XKimC38ojchj",
"toolName": "sendEmailTool",
"args": {
"to": "yamada@example.com",
"subject": "明日の打ち合わせ時間変更のお願い(10:00〜11:00)",
"body": "山田太郎様\n\nお世話になっております。\n明日の打ち合わせにつきまして、開始時間を10:00、終了時間を11:00に変更させていただきたく、ご連絡いたしました。\n\nご都合をご確認いただけますでしょうか。\n何卒よろしくお願いいたします。",
"priority": "high"
}
}
}レスポンスには suspendPayload が含まれます。ここを見れば、ユーザーに承認画面で表示すべき内容がわかります。
| フィールド | 用途 |
|---|---|
runId | 一時停止した Agent 実行を再開するための ID |
suspendPayload.toolCallId | 承認対象の tool call ID |
suspendPayload.toolName | 承認対象の tool 名 |
suspendPayload.args | 実行予定の引数 |
この時点では、sendEmailTool.execute() はまだ実行されていません。
一方で lookup-customer は承認不要なので、すでに実行されています。
サーバログにも顧客情報取得の結果が出ています。
lookup-customer result:
{
"customerId": "customer-123",
"name": "Yamada Taro",
"email": "yamada@example.com",
"plan": "Business",
"renewalDate": "2026-08-20",
"accountManager": "Sato"
}承認する
承認する場合は、approved: true を渡します。
$ curl -s -X POST http://localhost:3000/tool-approval-agent/generate/approvals \
-H 'Content-Type: application/json' \
-d '{
"runId": "5c8ce72c-53f1-498c-b7bf-d63d775e7300",
"toolCallId": "call_Hw3nAhAXRVn6XKimC38ojchj",
"approved": true
}'承認後は sendEmailTool が実行され、toolResults に送信結果が入ります。
{
"text": "customer-123(山田太郎様、yamada@example.com)へ、明日の打ち合わせを10:00〜11:00に変更したい旨のメールを優先度 high で送信しました。",
"finishReason": "stop",
"runId": "5c8ce72c-53f1-498c-b7bf-d63d775e7300",
"toolResults": [
{
"payload": {
"toolName": "sendEmailTool",
"result": {
"sent": true,
"messageId": "mock-email-1786426053165",
"to": "yamada@example.com",
"subject": "明日の打ち合わせ時間変更のお願い(10:00〜11:00)",
"priority": "high"
}
}
}
]
}ここで初めてメール送信済みとして扱えます。Agent の instructions にも、send-email tool が成功するまで、メールを送信済みだと主張しないでください と入れておくと、承認前後の表現が安定しやすくなります。
拒否する
拒否する場合は、同じ API に approved: false を渡します。
$ curl -s -X POST http://localhost:3000/tool-approval-agent/generate/approvals \
-H 'Content-Type: application/json' \
-d '{
"runId": "f9f6da9b-5158-4375-8d0b-b6e6091a06ca",
"toolCallId": "call_QURaOQTQXyLh4PURENRMjNE8",
"approved": false
}'拒否した場合、sendEmailTool は実行されません。Agent は拒否されたことを踏まえて、送信されていない状態を返します。
{
"text": "メール送信には承認が必要なため、まだ送信されていません。 \n以下の内容で送信準備ができています。\n\n- 宛先:suzuki@example.com(鈴木 花子様)\n- 件名:来週のお打ち合わせ日程調整のお願い\n- 優先度:normal\n\n承認いただければ送信します。",
"finishReason": "stop",
"runId": "f9f6da9b-5158-4375-8d0b-b6e6091a06ca"
}試していて気付いたこと
最初は instructions に、次の2つを両方書いていました。
解決した宛先メールアドレス、件名、本文、優先度を指定して send-email tool を使ってください。
send-email tool の実行前にはユーザーの承認が必要です。すると、モデルがすぐに send-email tool を呼び出さず、先に「この内容で送信してよろしいですか?」と通常のテキストで返すことがありました。
{
"text": "顧客情報を確認しました。\n\n- 宛先:山田太郎様(yamada@example.com)\n- 契約プラン:Business\n- 更新日:2026年8月20日\n- 担当者:Sato\n\n以下の内容で送信してよろしいですか?",
"finishReason": "stop",
"toolCalls": [
{
"payload": {
"toolName": "lookupCustomerTool"
}
}
]
}この場合は send-email tool が呼ばれていないので、Mastra の approval は発火しません。
Agent Approval は「承認対象 tool を実行しようとした瞬間に止める」仕組みなので、モデルが tool call ではなく通常のテキストで確認した場合は、通常の応答として扱われます。
そのため、instructions では「承認が必要だから tool を呼ぶ前にユーザーへ確認する」と読める書き方を避け、send-email tool を使ってください と明示するほうが挙動が安定しました。
つまり承認フロー自体は Mastra の requireApproval: true に完全に任せるほうが整理しやすいです。
アプリケーション側では、finishReason === "suspended" かどうかを見て承認フローに入るのが使いやすいかもしれません。
const result = await toolApprovalAgent.generate(message)
if (result.finishReason === 'suspended') {
return c.json({
status: 'approval_required',
runId: result.runId,
approval: result.suspendPayload,
})
}
return c.json({
status: 'completed',
text: result.text,
})まとめ
Mastra の Agent Approval を使うと、tool 単位で人間の承認を挟めます。
今回のように、顧客情報の検索は自動実行し、メール送信だけ承認必須にする場合は、送信 tool に requireApproval: true を付ければ実現できます。
generate() で実行する場合は、承認が必要になると finishReason: "suspended" と suspendPayload が返ります。アプリケーション側ではこの情報を使って確認画面を出し、承認されたら approveToolCallGenerate()、拒否されたら declineToolCallGenerate() を呼び出します。
LLM Agent を業務フローに入れるときは、読み取りと副作用のある操作を分けて設計するのが重要です。Agent Approval はその境界をコード上で表現しやすく、メール送信、チケット更新、CRM 更新、外部 API 実行のような処理に向いていると感じました。
Related notes
あわせて読みたいノート
MastraでAmazon Bedrock / Bedrock Guardrailsを使う方法
MastraからAmazon BedrockのモデルやBedrock Guardrailsを呼び出すための設定や実装を整理します。
続きを読む
ClaudeのWeb検索ツールを実際に呼び出して整理する
web_search / web_search_fast / web_fetch を使い、モバイルバッテリーの捨て方を題材にClaudeがページを探索する流れと、2つの検索ツールの出力の違いを確認します。
続きを読む
ChatGPTのWeb Searchツールを実際に呼び出して整理する
fast / slow / open / find / click を使い、モバイルバッテリーの捨て方を題材にWeb Searchがページを探索する流れを確認します。
続きを読む