2026年8月11日
MastraLLMAgent

Notebook

MastraのAgent Approvalでツール実行前に人間の承認を挟む

MastraのAgent Approvalを使って、読み取り系ツールは自動実行し、メール送信のような副作用のあるツールだけ承認後に実行する流れをHono APIから試します。

MastraLLMAgent ApprovalHuman in the Loop
English

Guide

目次

  1. 前提
  2. Agent を作成する
  3. Hono から呼び出す
  4. curl で実行する
  5. 承認する
  6. 拒否する
  7. 試していて気付いたこと
  8. まとめ

Mastra の Agent Approval を試したので、Hono から API として呼び出す形で実装を整理します。

Agent に tool を持たせると、顧客情報の検索やメール送信のような処理を自然言語から実行できます。ただし、すべての tool を自動実行したいわけではありません。検索のような読み取り処理はLLMの判断で自動実行しても良いと判断することも多いです。
一方でメール送信やデータ更新のように外部へ影響する処理は、人間の確認を挟みたいことがあります。

Mastra では、tool に requireApproval: true を指定すると、その tool の実行直前で Agent を一時停止できます。
公式ドキュメントでは Agent Approval として説明されています。

前提

今回は Hono と連携して API から実行できるようにしています。

Mastra の Hono ガイド

試す構成は以下です。

tool役割承認
lookup-customercustomer 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() で返ってきた runIdtoolCallId を使います。

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

あわせて読みたいノート