---
title: "Send email from a Vercel AI SDK agent"
description: "Send email from a Vercel AI SDK agent: connect createMCPClient to Unitpost or add one typed send tool, with an allowlist, approvals and idempotency keys."
url: https://www.unitpost.com/docs/ai/vercel-ai-sdk
section: MCP
updated: 2026-11-02
---
# Send email from a Vercel AI SDK agent

## Connection settings

- **Server URL**: https://mcp.unitpost.com/mcp
- **Transport**: Streamable HTTP
- **Auth (recommended)**: OAuth 2.1 — add the URL, approve in the browser (scopes match API capabilities; tokens refresh automatically)
- **Auth (legacy)**: Authorization: Bearer pk_live_YOUR_KEY (workspace API key from Settings → API keys; for clients that can only paste a key)
- **Tools**: one per API operation (email_send, email_campaigns_send, …) (the default set)
- **Toolsets (optional)**: https://mcp.unitpost.com/mcp?toolsets=default,campaigns (or the X-MCP-Toolsets header; add ?readonly=1 for read-only tools)

## Vercel AI SDK

> Send email from a Vercel AI SDK agent: connect createMCPClient to Unitpost or add one typed send tool, with an allowlist, approvals and idempotency keys.

Give an AI SDK agent the Unitpost send tool through the AI SDK MCP client, or one typed tool that calls the REST API. Either way your server code holds a send-only key and decides which sends go out.

### 1. Create a send-only key

Open https://www.unitpost.com → Settings → API keys and create a key with Sending only permissions. On a paid plan, open Advanced and keep only `emails:send`, so the key can send email and do nothing else. Copy the `pk_live_…` value (shown once) into the environment your agent runs in.

```bash
export UNITPOST_API_KEY=pk_live_YOUR_KEY
```

> A Sending only key can also send campaigns and read contacts, segments, templates and domains. Keep the key in your server code: the model never needs to see it.

### 2. Connect the AI SDK MCP client

Install `ai` and `@ai-sdk/mcp`. `createMCPClient` connects over streamable HTTP with your key as a Bearer header. `?toolsets=sending` keeps the server's list to sending tools, and the agent gets one of them: `email_send`.

```typescript
import { createMCPClient } from "@ai-sdk/mcp";
import { generateText, isStepCount } from "ai";

const mcp = await createMCPClient({
  transport: {
    type: "http",
    url: "https://mcp.unitpost.com/mcp?toolsets=sending",
    headers: { Authorization: `Bearer ${process.env.UNITPOST_API_KEY}` },
  },
});

try {
  const { email_send } = await mcp.tools();
  const result = await generateText({
    model: "anthropic/claude-sonnet-5.5", // any model the AI SDK supports
    tools: { email_send },
    stopWhen: isStepCount(5),
    prompt: "Email ada@example.com from hi@yourdomain.com: the report is ready.",
  });
  console.log(result.text);
} finally {
  await mcp.close();
}
```

> `email_send` takes an `idempotency_key` beside `body`. Tell the agent to pass a new one per message and the same one on a retry: the server's instructions say so too.

### 3. Or add one REST send tool

No MCP client needed: one typed `tool()` that posts to `/api/v1/email`. Your code holds the key, checks the recipient, and sends the result or the error envelope back to the model.

```typescript
import { tool } from "ai";
import { z } from "zod";

const ALLOWED = new Set(["ada@example.com"]); // who this agent may email

export const sendEmail = tool({
  description: "Send one email through Unitpost.",
  inputSchema: z.object({
    from: z.string().describe("Sender on a verified domain, e.g. Acme <hi@acme.com>"),
    to: z.string().describe("One recipient address"),
    subject: z.string(),
    text: z.string().describe("Plain-text body"),
    idempotency_key: z.string().describe("A new value for each message; the same value when retrying this one"),
  }),
  execute: async (email) => {
    if (!ALLOWED.has(email.to)) return { refused: `${email.to} is not on the allowlist` };
    const { idempotency_key, ...message } = email;
    const body = JSON.stringify(message);
    const res = await fetch("https://www.unitpost.com/api/v1/email", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.UNITPOST_API_KEY}`,
        "Content-Type": "application/json",
        "User-Agent": "my-agent/1.0",
        "Idempotency-Key": idempotency_key, // the model's: a retry of this message reuses it
      },
      body,
    });
    return res.json(); // { id, status, scheduled_at } or { error: { code, message, details } }
  },
});
```

> The model passes `idempotency_key`: a new one per message, and the same one when it retries that message, so a lost response never becomes a second email. A key made inside the tool would change on every retry. Never derive it from the content, or two separate identical notifications become one. `User-Agent` is required: a request without one is refused with `missing_user_agent` (403).

### 4. Gate each send

Every send is real mail to a real person. Send only transactional mail or mail people opted in to: the Acceptable Use Policy rules out cold outreach. Check each recipient against an allowlist, or have a person approve the send, before the call goes out. Pass an idempotency key, and the same key on a retry: for 24 hours a retry returns the first result instead of sending twice. In the AI SDK, `toolApproval` decides per call: deny any recipient off the allowlist and ask a person about the rest. The result then holds a `tool-approval-request`; answer it with a `tool-approval-response` and call `generateText` again.

```typescript
const ALLOWED = new Set(["ada@example.com"]);

const result = await generateText({
  model: "anthropic/claude-sonnet-5.5",
  tools: { email_send },
  prompt,
  toolApproval: ({ toolCall }) => {
    const body = (toolCall.input as { body?: Record<string, string | string[]> }).body ?? {};
    const recipients = [body.to, body.cc, body.bcc].flat().filter(Boolean) as string[];
    return recipients.length > 0 && recipients.every((a) => ALLOWED.has(a))
      ? "user-approval"
      : { type: "denied", reason: "A recipient is not on the allowlist." };
  },
});

for (const part of result.content) {
  if (part.type === "tool-approval-request" && !part.isAutomatic) {
    console.log("Approve this send?", part.toolCall.input); // show a person, then reply
  }
}
```

> Errors come back as `{ "error": { "code", "message", "details" } }`. Branch on `error.code` and fix the call instead of resending it: `domain_not_verified` means the `from` domain isn't verified, `validation_error` names the bad fields in `details`, and `rate_limit_exceeded` comes with a `Retry-After` wait. Over MCP the same error arrives under `data`, with a `hint` for the next move.

### 5. Verify the send

Make the first send to an address you own, from an address on a verified domain. A success returns the email's `id` and a `status` of `queued` (`scheduled` when you set `scheduled_at`); over MCP the same object comes back under `data`. Open Emails in the dashboard to see the message, its recipient and its delivery status, or read it back with `email_get` (MCP) or `GET /api/v1/email/{id}`.

```json
{
  "id": "email_…",
  "status": "queued",
  "scheduled_at": null
}
```

> A retry with the same idempotency key returns this same body (REST adds `Idempotent-Replayed: true`) and sends nothing new.

## Related

- [Give an OpenAI Agents SDK agent an email tool](https://www.unitpost.com/docs/ai/openai-agents-sdk): Give an OpenAI Agents SDK agent an email tool: MCPServerStreamableHttp to Unitpost or one function tool, with approvals and idempotency keys.
- [Send email from a LangChain agent](https://www.unitpost.com/docs/ai/langchain): Send email from a LangChain agent: load Unitpost's MCP tools with MCPAdapter or add one @tool, then gate sends with human-in-the-loop middleware.
- [Send email from a Mastra agent](https://www.unitpost.com/docs/ai/mastra): Send email from a Mastra agent: connect MCPClient to Unitpost or add one createTool send tool, with tool approval and idempotency keys.
