Email tools for an agent built with the Vercel AI SDK
The AI SDK's tool interface maps cleanly onto an inbox: typed parameters, a description the model reads, and a function that runs. The parts worth thinking about are which tools to define, what they return when something is refused, and where the untrusted message text is allowed to go.
3 min read
Four tools
import { tool } from 'ai';
import { z } from 'zod';
import { Mcpmailer } from '@mcpmailer/sdk';
const mail = new Mcpmailer(); // reads MCPMAILER_API_KEY
export const emailTools = {
getThread: tool({
description: 'The whole conversation, oldest first, quoted history removed. Call before writing.',
parameters: z.object({ threadId: z.string() }),
execute: async ({ threadId }) => mail.getThread(threadId)
}),
lookupContact: tool({
description: 'What the workspace already knows about this address. Call before writing.',
parameters: z.object({ email: z.string().email() }),
execute: async ({ email }) => mail.lookupContact(email)
}),
replyAll: tool({
description: 'Reply in thread, keeping the audience. Use instead of composing a new message.',
parameters: z.object({ messageId: z.string(), body: z.string() }),
execute: async ({ messageId, body }) => {
const res = await mail.replyAll(messageId, body);
// Refusals are answers, not exceptions: the model can act on a reason.
return res.status === 'rejected'
? { sent: false, reason: res.reason, retryAfter: res.retryAfter }
: { sent: true, messageId: res.messageId };
}
}),
escalate: tool({
description: 'Hand the thread to a human. Use when a rule fires or a claim cannot be sourced.',
parameters: z.object({ messageId: z.string(), why: z.string() }),
execute: async ({ messageId, why }) => {
await mail.markUnread(messageId);
await notifyTeam(messageId, why);
return { escalated: true };
}
})
};Two details carry most of the weight. The descriptions say *when* to call, not just what the tool does, which is what actually produces the read-thread-then-contact order. And replyAll returns a refusal as data rather than throwing, so the model can wait or escalate instead of retrying into a limit, per the refusal reasons.
Bound the loop
maxSteps is the difference between an agent that answers and one that discovers it can call tools repeatedly.
const result = await generateText({
model,
tools: emailTools,
maxSteps: 6,
system: SYSTEM_PROMPT,
prompt: `Handle the message ${event.message_id} on thread ${event.thread_id}.`
});Six is generous for this shape: thread, contact, maybe a lookup in your own systems, then one reply or one escalation. If a run regularly hits the ceiling, that is a signal about the prompt rather than a reason to raise it.
Where the untrusted text goes
The message body is attacker-controlled, and in the code above it arrives inside tool *results* rather than in your prompt, which is better but not sufficient: the model sees it either way and can be instructed by it.
For anything with real consequences, split the call, per prompt injection by email:
// 1. Read, with no tools bound at all.
const facts = await generateObject({
model,
schema: z.object({
intent: z.enum(['question', 'complaint', 'scheduling', 'other']),
orderId: z.string().optional(),
wantsHuman: z.boolean()
}),
prompt: threadText
});
// 2. Decide in code, where a message cannot argue with you.
if (facts.wantsHuman || !allowed(facts.intent)) return escalate(event, facts);
// 3. Act, with the tools bound and only the fields the action needs.
await generateText({ model, tools: emailTools, maxSteps: 4, prompt: buildActionPrompt(facts) });The first call cannot be talked into sending anything because it holds no tools. That property is worth more than any instruction in the system prompt.
Streaming, and why it does not apply here
The AI SDK's streaming is built for a user watching tokens appear. Nobody is watching an email agent, and a partially generated reply is not useful, so use the non-streaming call and keep the whole thing in a queue worker where it belongs, per webhooks or polling.
The one place streaming helps is a human-in-the-loop composer, where someone is reviewing a draft before it goes. That is a different product surface with a person in front of it.
The rest of the shape
Everything outside the model call is the same as any Node agent: verify the webhook signature over the raw body, acknowledge fast, queue the work, serialise per thread, and keep a handled-message set for idempotency. Those are in running an email agent on ordinary Node infrastructure, and they matter more than the model wiring, because they are what breaks under load.
Questions
- How do I give a Vercel AI SDK agent email access?
- Define tools with
tool()around the SDK or REST calls. Four cover most agents: get the thread, look up the contact, reply in thread, and escalate. - What should a tool description say?
- When to call it, not only what it does. "Call before writing" in the thread and contact tools is what produces the right order of operations.
- How should a refused send be returned?
- As data, with the reason and any retry-after. Throwing makes the model retry; returning a reason lets it wait or escalate.
- What should `maxSteps` be?
- Around six for this shape. Runs that regularly hit the ceiling indicate a prompt problem rather than a reason to raise it.
- Where does the untrusted message text belong?
- In a first call that holds no tools and returns structured facts. Decide in code, then act with tools bound and only the fields the action needs.
- Should I stream the reply?
- No. Nobody is watching, and a partial reply is not useful. Streaming only helps a human-in-the-loop composer.
Give your agent an address it can answer from.
Create an inbox