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.

  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. Attach the MCP connector

    Install anthropic. Pass the server in mcp_servers with your key as authorization_token (sent as a Bearer token), and allowlist email_send in the matching mcp_toolset. The connector needs the mcp-client-2025-11-20 beta.

    Python
    import 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 envelope

    The connector runs email_send inside the request, with no pause for approval. 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 client tool

    Your code runs this tool, so it can check and ask before the POST. @beta_tool builds the schema from the signature and docstring, and the tool runner loops until Claude is done.

    Python
    import 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-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. With the connector, the allowlist in mcp_toolset and 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 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.