Give a Vercel AI SDK agent its own inbox
To give a Vercel AI SDK agent email, create a Shipmail mailbox on your domain and pass its tools to generateText or streamText. Load them from the Shipmail MCP server with createMCPClient, or write four small tools with the Shipmail SDK. Either way the agent lists its inbox, reads conversations, sends and replies, and you approve 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 the AI SDK's own
toolApprovalsetting.
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 the model string from the AI SDK docs. A plain model string goes through the Vercel AI Gateway, which reads AI_GATEWAY_API_KEY. Use any model with tool calling that you already run.
Route 1: the Shipmail MCP server
The MCP client lives in @ai-sdk/mcp. Its HTTP transport connects to https://shipmail.to/api/mcp and sends the API key as a bearer token. mcpClient.tools() returns every Shipmail tool the key allows, ready for generateText.
bun add ai @ai-sdk/mcpimport { createMCPClient } from "@ai-sdk/mcp";
import { generateText, isStepCount } from "ai";
const mcpClient = await createMCPClient({
transport: {
type: "http",
url: "https://shipmail.to/api/mcp",
headers: { Authorization: `Bearer ${process.env.SHIPMAIL_API_KEY ?? ""}` },
},
});
try {
const tools = await mcpClient.tools();
const result = await generateText({
model: "anthropic/claude-sonnet-5.5",
tools,
stopWhen: isStepCount(10),
prompt:
"List the unread mail in agent@yourdomain.com. Draft a reply to each message that needs one.",
});
console.log(result.text);
} finally {
await mcpClient.close();
}With a Draft only key the server lists no send tool, so the replies wait in Drafts. Close the client when the response is done, or in the onEnd callback of streamText.
Approve sends from MCP
Once the key can send, add a toolApproval function that asks a person before every Shipmail tool except the read-only list, get and search tools. A send or reply tool then always waits for approval, including one added to the server later. Handle the approval request the same way as in route 2 below.
// 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);
const result = await generateText({
model: "anthropic/claude-sonnet-5.5",
tools,
toolApproval: ({ toolCall }) =>
needsApproval(toolCall.toolName) ? "user-approval" : undefined,
stopWhen: isStepCount(10),
prompt: "Reply to the latest email from jane@customer.com and confirm the call on Thursday.",
});The full list of tool names is on Shipmail MCP tools.
Route 2: your own tools with the Shipmail SDK
Four tools, each a call to the TypeScript SDK. The AI SDK's tool() takes a description, a zod inputSchema and an execute function. The send and reply tools pass the tool call ID as an idempotency key, so a retried call does not send twice.
bun add ai shipmail zodimport { tool } from "ai";
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 emailTools = {
list_inbox: tool({
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,
}));
},
}),
read_conversation: tool({
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,
}));
},
}),
send_email: tool({
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(),
}),
execute: async ({ to, subject, text }, { toolCallId }) => {
const message = await shipmail.messages.send(
{ from: FROM, to, subject, text },
// A retried call with the same ID sends nothing twice.
{ idempotencyKey: toolCallId },
);
return { id: message.id, status: message.status };
},
}),
reply: tool({
description: "Reply in an existing conversation.",
inputSchema: z.object({
conversation_id: z.string(),
to: z.array(z.string()).min(1),
text: z.string(),
}),
execute: async ({ conversation_id, to, text }, { toolCallId }) => {
const message = await shipmail.mailboxes.replyToInboxThread(
MAILBOX_ID,
conversation_id,
{ to, text },
{ idempotencyKey: toolCallId },
);
return { id: message.id, status: message.status };
},
}),
};Approve before sending
generateText does not pause for approval. It returns tool-approval-request parts instead. Ask a person, add a tool-approval-response for each one, and call the model again. A denied call never reaches Shipmail.
import { createInterface } from "node:readline/promises";
import { generateText, isStepCount, type ModelMessage, type ToolApprovalResponse } from "ai";
import { emailTools } from "./email-tools";
function run(messages: ModelMessage[]) {
return generateText({
model: "anthropic/claude-sonnet-5.5",
tools: emailTools,
// Reading runs on its own. Sending waits for a person.
toolApproval: { send_email: "user-approval", reply: "user-approval" },
stopWhen: isStepCount(10),
messages,
});
}
const messages: ModelMessage[] = [
{ role: "user", content: "Reply to the latest email from jane@customer.com and confirm the call on Thursday." },
];
let result = await run(messages);
messages.push(...result.responseMessages);
const rl = createInterface({ input: process.stdin, output: process.stdout });
const approvals: ToolApprovalResponse[] = [];
for (const part of result.content) {
if (part.type === "tool-approval-request" && !part.isAutomatic) {
const answer = await rl.question(
`Allow ${part.toolCall.toolName} ${JSON.stringify(part.toolCall.input)}? (y/n) `,
);
approvals.push({
type: "tool-approval-response",
approvalId: part.approvalId,
approved: answer.trim().toLowerCase() === "y",
});
}
}
rl.close();
if (approvals.length > 0) {
messages.push({ role: "tool", content: approvals });
result = await run(messages);
}
console.log(result.text);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 Mastra agent its own inbox
- Email for AI agents
- Webhooks to start a run when mail arrives.
Frequently asked questions
Should I use the MCP client or my own tools?
Use the MCP client to get every Shipmail action the key allows with no tool code to maintain. Write your own tools with the Shipmail SDK when you want a short, fixed tool list, your own descriptions, or a mailbox your code picks instead of the model.
My code uses needsApproval. Does it still work?
Yes. The AI SDK docs mark needsApproval on tool() as deprecated but still supported, and recommend toolApproval on generateText, streamText or ToolLoopAgent for new code.
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.
Can I use another model?
Yes. The model string in these samples is the one the AI SDK docs use. Any model the AI SDK supports with tool calling works, through the AI Gateway or a provider package.
How does the agent react to new mail?
Subscribe a webhook to message.received and start a run from your handler. The guide on receiving email in an AI agent covers signature checks, retries and sender checks.