Let a Claude API agent send email (MCP connector)
The Messages API can call the Unitpost MCP server itself through the MCP connector, with no MCP client on your side. For a person to approve each send, give Claude a client tool that calls the REST API instead.
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.
Attach the MCP connector
Install
anthropic. Pass the server inmcp_serverswith your key asauthorization_token(sent as a Bearer token), and allowlistemail_sendin the matchingmcp_toolset. The connector needs themcp-client-2025-11-20beta.Pythonimport os import anthropic client = anthropic.Anthropic() response = client.beta.messages.create( model="claude-opus-5-5", max_tokens=16000, betas=["mcp-client-2025-11-20"], mcp_servers=[ { "type": "url", "url": "https://mcp.unitpost.com/mcp", "name": "unitpost", "authorization_token": os.environ["UNITPOST_API_KEY"], } ], tools=[ { "type": "mcp_toolset", "mcp_server_name": "unitpost", "default_config": {"enabled": False}, "configs": {"email_send": {"enabled": True}}, } ], messages=[{"role": "user", "content": "Email ada@example.com from hi@yourdomain.com: the report is ready."}], ) for block in response.content: if block.type == "mcp_tool_result": print(block.content) # the send result, or the error envelopeThe connector runs
email_sendinside the request, with no pause for approval.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 client tool
Your code runs this tool, so it can check and ask before the POST.
@beta_toolbuilds the schema from the signature and docstring, and the tool runner loops until Claude is done.Pythonimport json import os import anthropic import httpx from anthropic import beta_tool ALLOWED = {"ada@example.com"} # who this agent may email def post_email(sender: str, to: str, subject: str, text: str, idempotency_key: str) -> str: body = json.dumps({"from": sender, "to": to, "subject": subject, "text": text}) res = httpx.post( "https://www.unitpost.com/api/v1/email", content=body, headers={ "Authorization": f"Bearer {os.environ['UNITPOST_API_KEY']}", "Content-Type": "application/json", "User-Agent": "my-agent/1.0", "Idempotency-Key": idempotency_key, # the model's: a retry reuses it }, ) return res.text # {"id": ...} or {"error": {"code": ..., "message": ...}} @beta_tool def send_email(sender: str, to: str, subject: str, text: str, idempotency_key: str) -> str: """Send one email through Unitpost. Args: sender: From address on a verified domain, e.g. "Acme <hi@acme.com>". to: One recipient address. subject: Subject line. text: Plain-text body. idempotency_key: A new value for each message; the same value when retrying this one, so a retry never sends twice. """ if to not in ALLOWED: return f"Refused: {to} is not on this agent's allowlist." return post_email(sender, to, subject, text, idempotency_key) client = anthropic.Anthropic() runner = client.beta.messages.tool_runner( model="claude-opus-5-5", max_tokens=16000, tools=[send_email], messages=[{"role": "user", "content": "Email ada@example.com from hi@yourdomain.com: the report is ready."}], ) print(runner.until_done())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. With the connector, the allowlist in
mcp_toolsetand a send-only key are the controls. With the client tool, ask inside the function before the POST: the tool runner waits for it to return.Python@beta_tool def send_email(sender: str, to: str, subject: str, text: str, idempotency_key: str) -> str: """Send one email through Unitpost. sender must be on a verified domain. idempotency_key: a new value for each message, and the same value when retrying that message, so a retry never sends twice. """ if to not in ALLOWED: return f"Refused: {to} is not on this agent's allowlist." if input(f"Send '{subject}' to {to}? [y/N] ").strip().lower() != "y": return "Refused: a person declined this send." return post_email(sender, to, subject, text, idempotency_key)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.