Give a Mastra agent its own inbox
To give a Mastra agent email, create a Shipmail mailbox on your domain and add its tools to the agent. Load them from the Shipmail MCP server with MCPClient, or write four tools with createTool and the Shipmail SDK. Either way the agent lists its inbox, reads conversations, sends and replies, and a person approves each send.
Last updated
What you get
- A real mailbox your team can open, so you see every message the agent handled.
- Tools to list the inbox, read a conversation, send a new email and reply in a conversation.
- Human approval on the send and reply tools with Mastra's own approval flow.
Create the mailbox and a scoped key
You need a Shipmail account with your domain added. Unlike a Gmail account made for a bot, a Shipmail mailbox is a paid business mailbox on your own domain, and giving it to an agent is what it is for. The address stays yours if you change agents later.
Create a mailbox for the agent
Under Mailboxes, create an address like
agent@yourdomain.com. You and your team can open it in Shipmail or any mail client and see every message the agent reads, drafts or sends.Create an API key
Open Developer, then API keys, and create a key. Pick the Draft only preset. It grants
mailboxes:read,messages:read,messages:write,drafts:write,drafts:compose,threads:readandsuppressions:read, and nomessages:send.Limit the key to that mailbox
Choose specific resources and add only the agent's mailbox. The key then cannot read or write any other mailbox in your organization.
The key starts with sm_live_ and is shown once. The MCP server only lists the tools the key's scopes allow, so a Draft only key has no send tool at all. When the drafts are good, edit the key and switch to the Full agent preset, which adds messages:send and members:read. Scope details are on API authentication.
Settings
| Environment variable | Value | Used for |
|---|---|---|
SHIPMAIL_API_KEY | sm_live_... | The scoped key from the step above. Both routes read it. |
SHIPMAIL_MAILBOX_ID | The agent mailbox ID | Only the SDK tools need it. GET /mailboxes or shipmail.mailboxes.list() returns it. |
SHIPMAIL_FROM | agent@yourdomain.com | The From address of new emails sent by the SDK tools. |
The samples use a model string from the Mastra docs. Use any model with tool calling that your Mastra project already runs.
Route 1: the Shipmail MCP server
MCPClient from @mastra/mcp connects to a remote server by URL. Pass the API key as a bearer token in requestInit.headers, and give the agent listTools(). It holds every Shipmail tool the key allows.
bun add @mastra/core@latest @mastra/mcp@latestimport { Agent } from "@mastra/core/agent";
import { MCPClient } from "@mastra/mcp";
// Read-only Shipmail MCP tools run on their own. A person approves every
// other call, so a send or reply tool added later still needs approval.
const READ_ONLY = /^shipmail_(list|get|search)_/;
const needsApproval = (name: string) => !READ_ONLY.test(name);
export const shipmailMcp = new MCPClient({
id: "shipmail-mcp",
servers: {
shipmail: {
url: new URL("https://shipmail.to/api/mcp"),
requestInit: {
headers: { Authorization: `Bearer ${process.env.SHIPMAIL_API_KEY ?? ""}` },
},
requireToolApproval: ({ toolName }) => needsApproval(toolName),
},
},
});
export const inboxAgent = new Agent({
id: "inbox-agent",
name: "Inbox agent",
instructions: "You answer the mail of agent@yourdomain.com. Read the whole conversation before you reply.",
model: "openai/gpt-6.1-sol",
tools: await shipmailMcp.listTools(),
});requireToolApproval pauses the run before every Shipmail tool except the read-only list, get and search tools, so a send or reply tool always waits for a person, including one added to the server later. In the agent, each tool name starts with the server key: shipmail_shipmail_send_message. All the tool names are on Shipmail MCP tools.
Route 2: your own tools with the Shipmail SDK
Four tools, each a call to the TypeScript SDK. createTool takes an id, a description, a zod inputSchema and execute, which receives the validated input. requireApproval: true on the send and reply tools stops them until a person agrees.
bun add @mastra/core@latest shipmail zodimport { createTool } from "@mastra/core/tools";
import { ShipmailClient } from "shipmail";
import { z } from "zod";
const shipmail = new ShipmailClient(process.env.SHIPMAIL_API_KEY ?? "");
const MAILBOX_ID = process.env.SHIPMAIL_MAILBOX_ID ?? "";
const FROM = process.env.SHIPMAIL_FROM ?? "";
export const listInbox = createTool({
id: "list-inbox",
description: "List recent messages in the agent's inbox.",
inputSchema: z.object({
unread_only: z.boolean().describe("Only messages nobody has opened yet"),
limit: z.number().int().min(1).max(50),
}),
execute: async ({ unread_only, limit }) => {
const page = await shipmail.mailboxes.listInboxMessages(MAILBOX_ID, {
folder_role: "inbox",
limit,
not_keyword: unread_only ? "$seen" : undefined,
});
return page.data.map((message) => ({
id: message.id,
conversation_id: message.conversation_id,
from: message.from?.[0]?.email ?? null,
subject: message.subject,
received_at: message.received_at,
preview: message.preview,
}));
},
});
export const readConversation = createTool({
id: "read-conversation",
description: "Read every message in one conversation.",
inputSchema: z.object({ conversation_id: z.string() }),
execute: async ({ conversation_id }) => {
const thread = await shipmail.mailboxes.getInboxThread(MAILBOX_ID, conversation_id);
return thread.data.map((message) => ({
from: message.from?.[0]?.email ?? null,
received_at: message.received_at,
subject: message.subject,
// What the sender newly wrote, without the quoted history.
text: message.reply_text ?? message.preview,
}));
},
});
export const sendEmail = createTool({
id: "send-email",
description: "Send a new email from the agent's address.",
inputSchema: z.object({
to: z.array(z.string()).min(1),
subject: z.string(),
text: z.string(),
}),
requireApproval: true,
execute: async ({ to, subject, text }) => {
const message = await shipmail.messages.send({ from: FROM, to, subject, text });
return { id: message.id, status: message.status };
},
});
export const reply = createTool({
id: "reply",
description: "Reply in an existing conversation.",
inputSchema: z.object({
conversation_id: z.string(),
to: z.array(z.string()).min(1),
text: z.string(),
}),
requireApproval: true,
execute: async ({ conversation_id, to, text }) => {
const message = await shipmail.mailboxes.replyToInboxThread(MAILBOX_ID, conversation_id, {
to,
text,
});
return { id: message.id, status: message.status };
},
});import { Agent } from "@mastra/core/agent";
import { listInbox, readConversation, reply, sendEmail } from "./email-tools";
export const inboxAgent = new Agent({
id: "inbox-agent",
name: "Inbox agent",
instructions: "You answer the mail of agent@yourdomain.com. Read the whole conversation before you reply.",
model: "openai/gpt-6.1-sol",
tools: { listInbox, readConversation, sendEmail, reply },
});Approve each send
Both routes pause the same way. The stream emits a tool-call-approval chunk with the tool name and arguments. Show them to a person, then call approveToolCall or declineToolCall with the run ID.
import { createInterface } from "node:readline/promises";
import { inboxAgent } from "./agent";
const rl = createInterface({ input: process.stdin, output: process.stdout });
const stream = await inboxAgent.stream(
"Reply to the latest email from jane@customer.com and confirm the call on Thursday.",
);
for await (const chunk of stream.fullStream) {
if (chunk.type === "tool-call-approval") {
const answer = await rl.question(
`Allow ${chunk.payload.toolName} ${JSON.stringify(chunk.payload.args)}? (y/n) `,
);
const next =
answer.trim().toLowerCase() === "y"
? await inboxAgent.approveToolCall({ runId: stream.runId })
: await inboxAgent.declineToolCall({ runId: stream.runId });
for await (const text of next.textStream) process.stdout.write(text);
}
}
rl.close();Storage
Mastra saves the paused run as a snapshot. Configure a storage provider on your Mastra instance, or approval fails with a "snapshot not found" error. Use a persistent one if approvals can wait across restarts.
Test without delivering
Use an sm_test_ key while you build. Shipmail accepts every send and delivers nothing. See the sandbox.
Stay safe
- Limit the key to the agent's mailbox, and start with Draft only.
- Treat every email body as untrusted input. Do not give the same agent tools that move money or change accounts.
- For an agent that reacts to new mail, check the sender before the model runs. See receive email in an AI agent and MCP security.
Next steps
- Give a Vercel AI SDK agent its own inbox
- Email for AI agents
- Webhooks to start a run when mail arrives.
Frequently asked questions
Should I use MCPClient or my own tools?
Use MCPClient to get every Shipmail action the key allows with no tool code to maintain. Write your own tools with createTool and the Shipmail SDK when you want a short, fixed tool list, your own descriptions, or a mailbox your code picks instead of the model.
Why are the MCP tool names different in my agent?
MCPClient puts the server key in front of each tool name to avoid collisions. With the server key shipmail, shipmail_send_message becomes shipmail_shipmail_send_message. requireToolApproval on the server receives the tool name the server publishes.
I get a snapshot not found error when I approve.
Mastra stores the paused run as a snapshot. Configure a storage provider on your Mastra instance, as the Mastra human-in-the-loop docs describe.
Why does the reply tool fail with a permission error?
Sending and replying need the messages:send scope, which the Draft only preset leaves out. Switch the key to the Full agent preset, or point the tool at shipmail.mailboxes.createInboxReplyDraft so replies wait in Drafts.
Each user has their own Shipmail key. How do I pass it?
Create an MCPClient per request with that user's key and pass listToolsets() to agent.generate or agent.stream as toolsets, instead of fixing tools on the agent.