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

Typed tools bound to a model call, with the inbox behind them
Four tools, typed. The interesting decisions are which four.

Four tools

TypeScript
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.

TypeScript
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:

TypeScript
// 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.

Read with no tools, decide in code, act with tools bound
The read call holds no tools, so it cannot be talked into sending.

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