---
title: "Send email from the Claude Agent SDK"
description: "Send email from the Claude Agent SDK: add Unitpost to mcpServers over HTTP, or one in-process tool, and approve each send in canUseTool."
url: https://www.unitpost.com/docs/ai/claude-agent-sdk
section: MCP
updated: 2026-11-02
---
# Send email from the Claude Agent SDK

## 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)

## Claude Agent SDK

> Send email from the Claude Agent SDK: add Unitpost to mcpServers over HTTP, or one in-process tool, and approve each send in canUseTool.

Add the Unitpost MCP server to a Claude Agent SDK agent (TypeScript), or give it one in-process tool that calls the REST API. The canUseTool callback decides each send before it runs.

### 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. Add the server to mcpServers

Install `@anthropic-ai/claude-agent-sdk`. Use `type: "http"` for streamable HTTP and put the key in `headers`. Tools are named `mcp__<server>__<tool>`, so the send tool is `mcp__unitpost__email_send`.

```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Email ada@example.com from hi@yourdomain.com: the report is ready.",
  options: {
    mcpServers: {
      unitpost: {
        type: "http",
        url: "https://mcp.unitpost.com/mcp?toolsets=sending",
        headers: { Authorization: `Bearer ${process.env.UNITPOST_API_KEY}` },
      },
    },
    // Approves every send without asking: fine for a first test to yourself.
    // The "Gate each send" step replaces it with canUseTool.
    allowedTools: ["mcp__unitpost__email_send"],
  },
})) {
  if (message.type === "result" && message.subtype === "success") console.log(message.result);
}
```

> `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 in-process send tool

No remote MCP needed: define the tool with `tool()`, wrap it in `createSdkMcpServer`, and pass that server in `mcpServers`. It posts to `/api/v1/email` and returns the result or the error envelope.

```typescript
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

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

const sendEmail = tool(
  "send_email",
  "Send one email through Unitpost.",
  {
    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"),
  },
  async (email) => {
    if (!ALLOWED.has(email.to)) {
      return { content: [{ type: "text", text: `Refused: ${email.to} is not on the allowlist.` }], isError: true };
    }
    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 { content: [{ type: "text", text: await res.text() }], isError: !res.ok };
  },
);

// query({ prompt, options: { mcpServers: { mailer } } }) names it mcp__mailer__send_email
export const mailer = createSdkMcpServer({ name: "mailer", version: "1.0.0", tools: [sendEmail] });
```

> 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 Agent SDK, `canUseTool` runs for every tool call that no allow rule approved, so leave the send tool out of `allowedTools` and decide there: allow it, or deny it with a message the agent reads.

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

for await (const message of query({
  prompt,
  options: {
    mcpServers, // as above, with no allowedTools entry for the send tool
    canUseTool: async (toolName, input) => {
      if (toolName !== "mcp__unitpost__email_send") {
        return { behavior: "deny", message: "This agent may only send email." };
      }
      const body = (input as { body?: { to?: string | string[]; cc?: string | string[]; bcc?: string | string[] } }).body ?? {};
      const recipients = [body.to, body.cc, body.bcc].flat().filter((r): r is string => Boolean(r));
      const blocked = recipients.filter((r) => !ALLOWED.has(r.toLowerCase()));
      return recipients.length > 0 && blocked.length === 0 // or ask a person here
        ? { behavior: "allow", updatedInput: input }
        : { behavior: "deny", message: `Not on the allowlist: ${blocked.join(", ") || "no recipient"}.` };
    },
  },
})) {
  if (message.type === "result" && message.subtype === "success") console.log(message.result);
}
```

> 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

- [Let a Claude API agent send email (MCP connector)](https://www.unitpost.com/docs/ai/claude-api): Let a Claude API agent send email: attach Unitpost with the MCP connector (mcp_servers plus an mcp_toolset) or a client tool you approve.
- [Send email from Cursor](https://www.unitpost.com/docs/ai/cursor): Send email from Cursor's agent through the Unitpost MCP server. Add the server URL, approve with OAuth in the browser, then send a test.
- [Send email from Claude Code](https://www.unitpost.com/docs/ai/claude-code): Send email from Claude Code with the Unitpost MCP server: add the URL, authenticate from /mcp, approve in the browser, then send a test.
