# Email for the OpenAI Agents SDK

VaEmail gives an agent built with the OpenAI Agents SDK (Python) the ability to send email, check what would block a send before it happens, and read the delivery status as a separate step. Two wirings work: attach the VaEmail MCP server with `MCPServerStdio`, or wrap the REST API in `function_tool` functions. VaEmail also publishes a Python SDK (`pip install vaemail`); the paths described here are plain MCP or plain HTTP. The reference example is `examples/openai-agents/agent.py` in [github.com/vaemail/examples](https://github.com/vaemail/examples).

Facts checked against the VaEmail documentation on 6 September 2026.

## The MCP wiring

The MCP server is the npm package `vaemail`, run over stdio, with no dependencies beyond Node 18. It exposes eighteen tools, including `vaemail_validate_email`, `vaemail_send_email` and `vaemail_get_message`. Read-only tools carry the `readOnlyHint` annotation, so an agent can tell which ones it may call without asking.

```python
"""An OpenAI Agents SDK agent that sends email through the VaEmail MCP server.

pip install openai-agents
VAEMAIL_API_KEY=swm_... OPENAI_API_KEY=... python agent.py
"""
import asyncio
import os

from agents import Agent, Runner
from agents.mcp import MCPServerStdio

INSTRUCTIONS = """You send email through VaEmail. Rules:
- Before the first real send, call vaemail_validate_email and stop if sendable is false.
- A send returns an id and status "queued": that means accepted, not delivered.
  Call vaemail_get_message before you report a delivery.
- Publishing DNS records or paying are human steps: name them, never declare them done."""


async def main() -> None:
    async with MCPServerStdio(
        name="vaemail",
        params={
            "command": "npx",
            "args": ["-y", "vaemail", "mcp"],
            "env": {**os.environ, "VAEMAIL_API_KEY": os.environ["VAEMAIL_API_KEY"]},
        },
        cache_tools_list=True,
    ) as server:
        agent = Agent(name="Mailer", instructions=INSTRUCTIONS, mcp_servers=[server])
        result = await Runner.run(
            agent,
            "Send an email to client@example.com, subject 'Your invoice', "
            "body '<p>Hello, your invoice is attached.</p>', then tell me its delivery status.",
        )
        print(result.final_output)


asyncio.run(main())
```

The rules in `INSTRUCTIONS` restate three of the four the MCP server returns at initialisation. The fourth is not in the example: when mail lands badly, call `vaemail_diagnose_deliverability` instead of guessing. The three that are there still separate an agent that reports a delivery it verified from one that guessed.

`vaemail_send_email` takes an `idempotency_key` argument. Set it on every send, derived from the business action: a repeat call replays the first response for 24 hours instead of sending twice.

## The function tool wiring

When the tool surface should match your own vocabulary, wrap the REST API instead. The order is fixed for the first send and after any configuration change: validate, send with an `Idempotency-Key`, then read the message. Base URL is `https://app.vaemail.fr`, and every route except `/api/v1/capabilities` expects the key in the `api-key` header.

```python
import json
import os
import urllib.error
import urllib.request

from agents import Agent, Runner, function_tool

BASE = os.environ.get("VAEMAIL_BASE_URL", "https://app.vaemail.fr")
KEY = os.environ["VAEMAIL_API_KEY"]


def call(method, path, body=None, headers=None):
    req = urllib.request.Request(
        BASE + path,
        data=json.dumps(body).encode() if body else None,
        method=method,
        headers={"api-key": KEY, "Content-Type": "application/json", **(headers or {})},
    )
    try:
        with urllib.request.urlopen(req) as res:
            return json.load(res)["data"]
    except urllib.error.HTTPError as err:
        return {"error": json.load(err).get("error")}


_validated = False


@function_tool
def send_email(to: str, subject: str, html: str, idempotency_key: str) -> dict:
    """Queue one transactional email. Dry run first when the configuration changed
    or nothing has been sent yet. Returns its id and status. idempotency_key must be
    derived from the business action, for example invoice-2412-client@example.com,
    and must be identical on a retry of the same action."""
    global _validated
    message = {"to": to, "subject": subject, "html": html}
    if not _validated:
        check = call("POST", "/api/v1/messages/validate", message)
        if "error" in check:
            return {"sent": False, "error": check["error"]}
        if not check.get("sendable"):
            return {"sent": False, "blocking": check.get("blocking")}
        _validated = True
    return call("POST", "/api/v1/transactional/send", message,
                {"Idempotency-Key": idempotency_key})


@function_tool
def get_message(message_id: int) -> dict:
    """Delivery status and events for one message. Only "delivered" means it arrived."""
    return call("GET", f"/api/v1/messages/{message_id}")


agent = Agent(
    name="Mailer",
    instructions=INSTRUCTIONS,
    tools=[send_email, get_message],
)
```

`POST /api/v1/messages/validate` answers `200` and sends nothing. It returns `sendable`, a `blocking` list carrying the same error codes as a real send, plus `quota_remaining` and `daily_key_remaining`. `POST /api/v1/transactional/send` answers `202` with `{"data": {"id": 2841, "status": "queued"}}`. `status` is `queued`, or `held` when the account holds the message. Either way it means accepted, not arrived. Delivery is read on `GET /api/v1/messages/{id}`, where `status` is one of `queued`, `sent`, `delivered`, `failed`, `suppressed`, `held`, `cancelled`. Only `delivered` supports the sentence "the email arrived".

Derive `idempotency_key` from the business action, `invoice-2412-client@example.com` rather than a random string. The header is remembered for 24 hours and its response replayed with `Idempotent-Replay: true`, so a retry after a network timeout does not send a second message. The same key with a different body is refused with `409 IDEMPOTENCY_KEY_REUSED`, which is a bug in the key derivation, not a replay to retry. Server errors are not memorised, so a 5xx can be retried with the same key. The send route is limited to 60 requests per minute.

Both wirings assume a sending domain already authenticated: mail from a domain without SPF and DMARC is filtered, and a send from an unauthenticated domain is blocked with `DOMAIN_NOT_VERIFIED`. Declaring the domain is `POST /api/v1/domains` (MCP: `vaemail_create_domain`); publishing the records at the registrar is a human step. See https://vaemail.fr/docs/domains.md

## Environment variables

- `VAEMAIL_API_KEY`: the VaEmail key, format `swm_...`. Read by the MCP server, the CLI and your own code. Never in source, never in a committed file, never echoed back.
- `VAEMAIL_BASE_URL`: optional, to point at another environment. Default `https://app.vaemail.fr`.
- `OPENAI_API_KEY`: for the Agents SDK itself, as in the example header.

Nothing else is read by the MCP server or the CLI. To check the wiring before the first run, `npx vaemail init` reports whether the service answers, whether the key is accepted and whether a sending domain is authenticated, and prints the MCP configuration to paste.

## A key scoped to the agent

Creating the account and the key is a human step: there is no signup endpoint, and the key is shown once. Ask for one dedicated to this agent, bounded at creation:

- scopes: `email.send` and `email.read_status` cover sending and tracking; add `billing.read` to read `GET /api/v1/usage` and `analytics.read` to read `GET /api/v1/audit-logs`; add `domain.read` and `domain.write` only if the agent configures the sending domain;
- `max_emails_per_day`: the volume the agent is meant to produce, no more;
- `max_recipients_per_email`: 1 for transactional mail;
- `allowed_domains`: the single sender domain planned.

A call outside its scopes gets `403 INSUFFICIENT_SCOPE`, naming the missing scope and the ones the key holds, so the agent can say what it lacks. Over the daily cap, `429 DAILY_LIMIT_REACHED`, marked `retryable: true` since the cap resets. `GET /api/v1/usage` (scope `billing.read`) returns the monthly quota, today's sends and what remains on the key. `GET /api/v1/audit-logs` (scope `analytics.read`) returns one line per call, without message bodies, attachments or merge variables. Both calls need a scope beyond sending and tracking: add `billing.read` and `analytics.read` to the key if the agent is meant to report its own consumption, otherwise both answer 403 INSUFFICIENT_SCOPE.

Every error body carries `error.code`, stable over time, plus `retryable`, `resolution` and `documentation`. Branch on the code, never on the message text.

## What this setup will not do

An Agents SDK agent cannot get its own inbox from VaEmail. There is no inbox created on demand, so no address to receive a verification code or a human reply; inbound exists only for prospecting sequences attached to a declared IMAP mailbox. The npm MCP server runs over stdio. A separate remote connector at `mcp.vaemail.fr`, with OAuth, exposes 16 tools. Six webhook events are published: sent, delivered, bounce, complaint, opened, clicked. The records themselves come from `vaemail_create_domain`, `npx vaemail domains:add example.com` or `GET /api/v1/domains/{domain}/dns`. A record marked `publishable: false` still holds a placeholder: hand it back for a value, never publish it as-is. Publishing DNS records at the registrar, paying, and sending a campaign to a whole list stay human decisions, the last one guarded by an explicit confirmation in the request body of `POST /v3/emailCampaigns/{id}/sendNow`, on the Brevo-compatible surface `https://app.vaemail.fr/v3`, the same surface that lets an existing Brevo client work unchanged. VaEmail is a young product run by an agency, and its reference documentation is partly in French. Prices are public on https://vaemail.fr/tarifs/.

## Copy this to your agent

```
Add VaEmail to this project as the outbound email provider, from an OpenAI Agents SDK agent.
Documentation for machines: https://vaemail.fr/llms.txt
OpenAPI specification: https://app.vaemail.fr/openapi.json
What the service can do, without a key: https://app.vaemail.fr/api/v1/capabilities

Rules:
- The API key is in the environment variable VAEMAIL_API_KEY. Do not ask for it, and never
  write it in a versioned file.
- Wire it either with MCPServerStdio(params={"command": "npx", "args": ["-y", "vaemail", "mcp"], "env": {**os.environ, "VAEMAIL_API_KEY": os.environ["VAEMAIL_API_KEY"]}})
  or with function_tool functions calling https://app.vaemail.fr/api/v1 with the api-key header.
- Before any real send, call POST /api/v1/messages/validate (MCP: vaemail_validate_email)
  and stop if "sendable" is false.
- Set an idempotency key on every send, derived from the business action: the Idempotency-Key header over REST, the idempotency_key argument of vaemail_send_email over MCP.
- A 202 means accepted, not delivered: read GET /api/v1/messages/{id}
  (MCP: vaemail_get_message) before reporting a delivery.
- Publishing DNS records, creating an account and paying are human steps: name them,
  never declare them done.
```

## Links

- Agent page: https://vaemail.fr/agents
- Agent skill: https://vaemail.fr/skill
- MCP server, the eighteen tools: https://vaemail.fr/docs/mcp.md
- API reference: https://vaemail.fr/docs/api.md
- Error codes: https://vaemail.fr/docs/errors.md
- Scopes, caps, idempotency, audit log: https://vaemail.fr/docs/security.md
- Sending domains: https://vaemail.fr/docs/domains.md
- Whole documentation in one file: https://vaemail.fr/llms-full.txt
- OpenAPI 3.1: https://app.vaemail.fr/openapi.json
- Runnable examples: https://github.com/vaemail/examples
- MCP server source: https://github.com/vaemail/vaemail-mcp
- What an agent needs from an email API: https://vaemail.fr/email-api-for-ai-agents
- The same wiring for other platforms: https://vaemail.fr/email-for-codex and https://vaemail.fr/email-for-langchain
- Deliverability an agent can diagnose itself: https://vaemail.fr/ai-agent-email-deliverability
