Send email from the Claude Agent SDK

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.

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

Search docs and guides

Search the docs and product guides.