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.
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 thepk_live_…value (shown once) into the environment your agent runs in.Shellexport UNITPOST_API_KEY=pk_live_YOUR_KEYA 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.
Add the server to mcpServers
Install
@anthropic-ai/claude-agent-sdk. Usetype: "http"for streamable HTTP and put the key inheaders. Tools are namedmcp__<server>__<tool>, so the send tool ismcp__unitpost__email_send.TypeScriptimport { 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_sendtakes anidempotency_keybesidebody. Tell the agent to pass a new one per message and the same one on a retry: the server's instructions say so too.Or add one in-process send tool
No remote MCP needed: define the tool with
tool(), wrap it increateSdkMcpServer, and pass that server inmcpServers. It posts to/api/v1/emailand returns the result or the error envelope.TypeScriptimport { 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-Agentis required: a request without one is refused withmissing_user_agent(403).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,
canUseToolruns for every tool call that no allow rule approved, so leave the send tool out ofallowedToolsand decide there: allow it, or deny it with a message the agent reads.TypeScriptconst 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 onerror.codeand fix the call instead of resending it:domain_not_verifiedmeans thefromdomain isn't verified,validation_errornames the bad fields indetails, andrate_limit_exceededcomes with aRetry-Afterwait. Over MCP the same error arrives underdata, with ahintfor the next move.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
idand astatusofqueued(scheduledwhen you setscheduled_at); over MCP the same object comes back underdata. Open Emails in the dashboard to see the message, its recipient and its delivery status, or read it back withemail_get(MCP) orGET /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.