Send email from a Pydantic AI agent
Attach the Unitpost MCP server to a Pydantic AI agent as a toolset filtered to the send tool, or register one tool that calls the REST API. Deferred approvals end the run until a person decides.
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 an MCPToolset
Install
pydantic-ai-slim[mcp].MCPToolsettakes the key asauthand sends it as a Bearer token;.filtered()keeps one tool:email_send.Pythonimport asyncio import os from pydantic_ai import Agent from pydantic_ai.mcp import MCPToolset unitpost = MCPToolset("https://mcp.unitpost.com/mcp?toolsets=sending", auth=os.environ["UNITPOST_API_KEY"]).filtered( lambda ctx, tool_def: tool_def.name == "email_send" ) agent = Agent("openai:gpt-6.1-sol", toolsets=[unitpost]) async def main(): result = await agent.run("Email ada@example.com from hi@yourdomain.com: the report is ready.") print(result.output) asyncio.run(main())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 REST send tool
No MCP needed: one
@agent.tool_plainfunction that posts to/api/v1/email.requires_approval=Truedefers every call to it until it's approved.Pythonimport json import os import httpx from pydantic_ai import Agent, DeferredToolRequests ALLOWED = {"ada@example.com"} # who this agent may email agent = Agent("openai:gpt-6.1-sol", output_type=[str, DeferredToolRequests]) @agent.tool_plain(requires_approval=True) async 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." body = json.dumps({"from": sender, "to": to, "subject": subject, "text": text}) async with httpx.AsyncClient() as http: res = await http.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": ...}}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 Pydantic AI,
requires_approval=Trueon the tool, or.approval_required()on the MCP toolset, ends the run with aDeferredToolRequestsoutput. Answer each pending call, then run again with the message history.Pythonfrom pydantic_ai import DeferredToolRequests, DeferredToolResults # For MCP: Agent(..., toolsets=[unitpost.approval_required()], output_type=[str, DeferredToolRequests]) async def run_with_approval(prompt: str) -> str: result = await agent.run(prompt) while isinstance(result.output, DeferredToolRequests): approvals = {} for call in result.output.approvals: answer = input(f"Send {call.tool_name} with {call.args}? [y/N] ") approvals[call.tool_call_id] = answer.strip().lower() == "y" result = await agent.run( message_history=result.all_messages(), deferred_tool_results=DeferredToolResults(approvals=approvals), ) return result.outputErrors 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.