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.
Guide
Contents
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.
The example uses two tools.
| Tool | Role | Approval |
|---|---|---|
lookup-customer | Look up customer details from a customer ID | Not required |
send-email | Send an email to a customer | Required |
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.
| Field | Purpose |
|---|---|
runId | ID for resuming the suspended Agent run |
suspendPayload.toolCallId | Tool call ID that needs approval |
suspendPayload.toolName | Tool name that needs approval |
suspendPayload.args | Arguments 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
Using Amazon Bedrock and Bedrock Guardrails with Mastra
This note explains how to call Amazon Bedrock models and Bedrock Guardrails from Mastra.
Read more
How to Apply Amazon Bedrock Guardrails to Only Part of an Input
This note explains how to limit the scope of Amazon Bedrock Guardrails to part of an input text by using InvokeModel input tags and Converse guardContent blocks.
Read more
Fixing a Promptfoo result collision bug with duplicate provider ids
I fixed a Promptfoo bug where evaluation results collided and were displayed incorrectly when multiple providers used the same provider id.
Read more