August 11, 2026
MastraLLMAgent

Notebook

Using Mastra Agent Approval to Require Human Confirmation Before Tool Execution

This note shows how to use Mastra Agent Approval from a Hono API, letting read-only tools run automatically while requiring approval for side-effect tools such as sending email.

MastraLLMAgent ApprovalHuman in the Loop
日本語

Guide

Contents

  1. Prerequisites
  2. Creating the Agent
  3. Calling It from Hono
  4. Testing with curl
  5. Approving the Tool Call
  6. Declining the Tool Call
  7. What I Noticed
  8. Summary

I tried Mastra Agent Approval, so this note summarizes how I wired it into a Hono API.

When an Agent has tools, it can execute operations such as looking up customer details or sending email from a natural language request. But not every tool should run automatically. Read-only operations such as lookup tools are often fine to execute based on the model's judgment.
On the other hand, operations with external side effects, such as sending email or updating data, usually need human confirmation.

In Mastra, adding requireApproval: true to a tool lets the Agent suspend immediately before that tool executes.
The official documentation describes this as Agent Approval.

Prerequisites

This example uses Hono so the Agent can be called through API endpoints.

Mastra Hono guide

The example uses two tools.

ToolRoleApproval
lookup-customerLook up customer details from a customer IDNot required
send-emailSend an email to a customerRequired

The read-only lookup-customer tool runs normally. Only the side-effecting send-email tool requires approval.

Creating the Agent

First, create a tool for looking up customer details. This tool is read-only, so it does not need approval.

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: '[email protected]',
    plan: 'Business',
    renewalDate: '2026-08-20',
    accountManager: 'Sato',
  },
  'customer-456': {
    customerId: 'customer-456',
    name: 'Suzuki Hanako',
    email: '[email protected]',
    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;
  },
});

Next, create the email-sending tool. This is where requireApproval: true is added.

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,
    };
  },
});

Then pass both tools to the Agent.

export const toolApprovalAgent = new Agent({
  id: 'tool-approval-agent',
  name: 'Tool Approval Agent',
  instructions: `You are a customer support assistant.
 
Help users look up customer details and send customer emails.
 
When a user asks you to send an email:
- If the user provides a customer ID, first use the lookup-customer tool to retrieve the recipient's name, email address, plan, renewal date, and account manager.
- Use the retrieved customer details and the user's request to compose a concise, polite subject and body.
- Use the send-email tool with the resolved recipient email address, subject, body, and priority.
- If the user clearly asked you to send an email, do not stop to ask "Should I send it?" yourself. Call the send-email tool.
- Mastra pre-execution approval handles the send confirmation. Proceed to the tool call instead of asking for extra confirmation.
- Never claim an email was sent unless the send-email tool succeeds.
- If the customer ID, recipient email address, or email purpose is missing, ask a brief follow-up question before using the sending tool.
 
Known demo customer IDs are customer-123 and customer-456.`,
  model: 'openai/gpt-5.6-luna',
  tools: { lookupCustomerTool, sendEmailTool },
});

The important part is that approval is configured on the tool that needs it. With this setup, the Agent can run lookup-customer immediately, but it suspends before executing send-email.

Calling It from Hono

Create one API endpoint for calling the Agent with generate(), and another endpoint for approving or declining the suspended tool call.

Mastra also supports streaming, but this example uses generate() because it is easier to inspect the full response while testing Agent Approval.

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))
})

For the approval endpoint, use the runId and toolCallId returned from the first generate() response.

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))
})

When using generate(), call approveToolCallGenerate() to approve the suspended tool call and declineToolCallGenerate() to decline it.

Testing with curl

First, ask the Agent to send an email.

$ curl -s -X POST http://localhost:3000/tool-approval-agent/generate \
  -H "Content-Type: application/json" \
  -d '{"message":"Please email customer-123 to say that I would like to change the meeting time tomorrow to 10:00-11:00. Priority is high."}'

Because send-email requires approval, the response has finishReason: "suspended".

{
  "text": "",
  "finishReason": "suspended",
  "runId": "ce2906f0-c7b6-4139-8522-d6c2630a6da0",
  "suspendPayload": {
    "toolCallId": "call_FRWNLFhnRnuBth21EGU9JiCn",
    "toolName": "sendEmailTool",
    "args": {
      "to": "[email protected]",
      "subject": "Request to Change Tomorrow's Meeting Time",
      "body": "Hello Taro,\n\nI would like to change our meeting time tomorrow to 10:00-11:00. Please let me know if that works for you.\n\nBest regards",
      "priority": "high"
    }
  }
}

The suspendPayload contains the data an application can show in an approval screen.

FieldPurpose
runIdID for resuming the suspended Agent run
suspendPayload.toolCallIdTool call ID that needs approval
suspendPayload.toolNameTool name that needs approval
suspendPayload.argsArguments that will be passed to the tool if approved

At this point, sendEmailTool.execute() has not run yet.
However, lookup-customer does not require approval, so it has already run and appears in toolResults.

Approving the Tool Call

To approve the email send, pass approved: true.

$ curl -N -X POST http://localhost:3000/tool-approval-agent/generate/approvals \
  -H 'Content-Type: application/json' \
  -d '{
    "runId": "ce2906f0-c7b6-4139-8522-d6c2630a6da0",
    "toolCallId": "call_FRWNLFhnRnuBth21EGU9JiCn",
    "approved": true
  }'

After approval, sendEmailTool runs and the result appears in toolResults.

{
  "text": "Email sent successfully to Yamada Taro at **[email protected]** with **high priority**.",
  "finishReason": "stop",
  "runId": "ce2906f0-c7b6-4139-8522-d6c2630a6da0",
  "toolResults": [
    {
      "payload": {
        "toolName": "sendEmailTool",
        "result": {
          "sent": true,
          "messageId": "mock-email-1786482665710",
          "to": "[email protected]",
          "subject": "Request to Change Tomorrow's Meeting Time",
          "priority": "high"
        }
      }
    }
  ]
}

Only after this point should the application treat the email as sent.

Declining the Tool Call

For the decline path, start another run.

$ curl -s -X POST http://localhost:3000/tool-approval-agent/generate \
  -H "Content-Type: application/json" \
  -d '{"message":"Please email customer-456 to say that I would like to schedule a meeting sometime next week and coordinate a suitable date. Priority is normal."}'

The Agent again suspends before send-email.

{
  "text": "",
  "finishReason": "suspended",
  "runId": "ff389ca6-7799-4d7c-a449-edfcea0da8d0",
  "suspendPayload": {
    "toolCallId": "call_rhlfFKednXgA5QSD9p2dLzof",
    "toolName": "sendEmailTool",
    "args": {
      "to": "[email protected]",
      "subject": "Scheduling a Meeting Next Week",
      "body": "Hello Suzuki Hanako,\n\nI would like to schedule a meeting sometime next week. Could you please share your availability so we can coordinate a suitable date and time?\n\nBest regards",
      "priority": "normal"
    }
  }
}

Then decline it with approved: false.

$ curl -N -X POST http://localhost:3000/tool-approval-agent/generate/approvals \
  -H 'Content-Type: application/json' \
  -d '{
    "runId": "ff389ca6-7799-4d7c-a449-edfcea0da8d0",
    "toolCallId": "call_rhlfFKednXgA5QSD9p2dLzof",
    "approved": false
  }'

When declined, sendEmailTool does not run.

{
  "text": "The email was not sent because approval was not granted.",
  "finishReason": "stop",
  "runId": "ff389ca6-7799-4d7c-a449-edfcea0da8d0"
}

This gives the application a clear state: the Agent prepared the email, but the side-effecting tool was not executed.

What I Noticed

At first, my instructions contained both of these ideas.

Use the send-email tool with the resolved recipient email address, subject, body, and priority.
The send-email tool requires user approval before execution.

With wording like that, the model sometimes did not call send-email right away. Instead, it returned a normal text confirmation such as "Should I send this email?" after running only lookup-customer.

In that case, Mastra approval does not trigger because the approval mechanism only activates when the Agent actually attempts to call a tool with requireApproval: true. If the model asks for confirmation in plain text instead of calling the tool, it is just a normal Agent response.

So I changed the instructions to make the separation explicit.

If the user clearly asked you to send an email, do not stop to ask "Should I send it?" yourself. Call the send-email tool.
Mastra pre-execution approval handles the send confirmation. Proceed to the tool call instead of asking for extra confirmation.
Never claim an email was sent unless the send-email tool succeeds.

In other words, the Agent should proceed to the tool call, and Mastra's requireApproval: true should own the approval flow.

On the application side, it is convenient to branch on 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,
})

Summary

Mastra Agent Approval lets you add human confirmation at the tool level.

For a flow like this example, where customer lookup can run automatically but email sending must be approved, add requireApproval: true only to the sending tool.

When using generate(), a required approval returns finishReason: "suspended" and a suspendPayload. The application can show that payload to the user, then call approveToolCallGenerate() if approved or declineToolCallGenerate() if declined.

When adding LLM Agents to business workflows, separating read-only tools from side-effecting tools is important. Agent Approval is a practical way to express that boundary in code, especially for operations such as sending email, updating tickets, modifying CRM records, or calling external APIs.

Related notes

Read next