Integrations · September 22, 2026

Pydantic AI Email Tool: Typed Send Results

Give a Pydantic AI email tool a discriminated union return type, so a model can never read accepted as delivered or a queued draft as a sent message.

Most send tools return a string. The model reads "sent" and writes a sentence to a customer saying their receipt has arrived. Neither is true yet. A send against this service has two different HTTP 202 shapes, one of which means no email exists at all, and after that a handful of terminal states, one of which means nobody knows whether the message left the building. A str return type erases every one of those distinctions before the model ever sees them.

This piece builds a Pydantic AI email tool whose return type is a discriminated union over the states the system actually reports, names the real event vocabulary the union branches on, and shows where the framework will and will not put that type in front of the model. If you want the framework-agnostic version of the same loop, read the agent email API loop instead. Other client wiring lives on the integrations hub.

Two success shapes, and why a string collapses them

A send returns HTTP 202 in both success cases, and the discriminator is the result field. These are the two response schemas, from packages/contracts/src/openapi.ts:

{
  "result": "queued",
  "message_id": "msg_...",
  "thread_id": "thd_...",
  "state": "queued",
  "request_id": "req_..."
}
{
  "result": "approval_required",
  "draft_id": "drf_...",
  "draft_version": 1,
  "approval_id": "apr_...",
  "reason_codes": ["always"],
  "request_id": "req_..."
}

approval_required is not a slow send. It is the absence of a send. No message row exists, no provider has been contacted, and nothing will happen until a human acts in the console. The reason_codes array tells you why: always from an inbox rule, new_recipient_domain, attachments when the payload carries any, or draft_only_principal when the credential itself holds drafts:submit without messages:send (packages/domain/src/services.ts:1590 and :1687). The approval expires after seven days.

Return str from your tool and both shapes arrive at the model as prose it has to re-parse. Return a union and the model gets a tagged object it can branch on, while your own code gets a type checker that refuses to let you read message_id off an approval.

What a Pydantic AI email tool should return instead of a string

Two models and a discriminated alias. The discriminator is the same result field the API already sends, so no translation layer is needed:

from typing import Annotated, Literal
from pydantic import BaseModel, Field

ReasonCode = Literal[
    "always", "new_recipient_domain", "attachments", "draft_only_principal"
]

class Queued(BaseModel):
    """The message was stored and accepted for processing. Nothing has been
    transmitted and no recipient has received anything."""
    result: Literal["queued"]
    message_id: str
    thread_id: str
    state: Literal["queued"]
    request_id: str

class ApprovalRequired(BaseModel):
    """No email exists. A human must approve the draft before anything is sent."""
    result: Literal["approval_required"]
    draft_id: str
    draft_version: int
    approval_id: str
    reason_codes: list[ReasonCode]
    request_id: str

SendOutcome = Annotated[Queued | ApprovalRequired, Field(discriminator="result")]

A tagged union is worth the extra line over a bare Queued | ApprovalRequired. Pydantic validates against exactly one member instead of trying each in order, so a malformed approval_required payload reports a missing approval_id rather than a confusing list of failures against both branches. The docstrings are not decoration either, and the next section explains which part of them reaches the model.

Writing the tool against the REST base, and the 400 you hit first

The REST base is https://api.emailforagents.ai/v1, credentials go in Authorization: Bearer, and the send path is /projects/{project_id}/inboxes/{inbox_id}/messages. Dependencies carry the client and the IDs so the model never sees them:

from dataclasses import dataclass
import httpx
from pydantic import TypeAdapter
from pydantic_ai import Agent, ModelRetry, RunContext
from pydantic_ai.exceptions import ToolFailed

BASE = "https://api.emailforagents.ai/v1"
send_outcome = TypeAdapter(SendOutcome)

@dataclass
class Mailbox:
    client: httpx.AsyncClient
    project_id: str
    inbox_id: str

agent = Agent("openai:gpt-5.2", deps_type=Mailbox)

@agent.tool
async def send_email(
    ctx: RunContext[Mailbox],
    to: str,
    subject: str,
    text: str,
    idempotency_key: str,
) -> SendOutcome:
    """Send one email from this agent's own inbox.

    Args:
        to: A single recipient address.
        subject: Subject line, 1 to 998 characters.
        text: Plain text body.
        idempotency_key: A stable key naming the thing being sent, such as
            "invoice-4471-receipt-v1". Never a fresh value per attempt.

    Returns:
        Either {"result": "queued"} with a message_id, or
        {"result": "approval_required"} with an approval_id, which means no
        email exists yet and a human must act. Neither one means delivered.
    """
    reply = await ctx.deps.client.post(
        f"{BASE}/projects/{ctx.deps.project_id}/inboxes/{ctx.deps.inbox_id}/messages",
        headers={"Idempotency-Key": idempotency_key},
        json={"to": [{"email": to}], "subject": subject, "text": text},
    )
    if reply.status_code == 400:
        fields = reply.json()["error"]["details"].get("fields", [])
        raise ModelRetry(f"The request was rejected. Bad fields: {fields}")
    if not reply.is_success:
        raise ToolFailed(reply.json()["error"]["code"])
    return send_outcome.validate_python(reply.json())

Three shape rules bite on the first run. to is an array of objects with an email key, not a bare string. Idempotency-Key is a required header on send, and a missing one raises HTTP 400 invalid_request before the body is parsed at all (packages/api/src/index.ts:238). The 128-character ceiling people quote for that header is not on this route, it is on the approval approve route (:219), while the MCP tools take the key as a body field bounded to 1 to 200 characters. Stay under 128 and every path accepts the same key. And the request schema is strict, so an unknown top-level field is a 400 rather than an ignored key. sendMessageRequestSchema in packages/contracts/src/schemas.ts ends in .strict(), and the API error handler turns any validation failure into HTTP 400 invalid_request with details.fields naming the offenders (packages/api/src/index.ts:82). Send "from", "body", "headers" or "tags" alongside a valid payload and the whole request is rejected.

That strictness changes what ModelRetry is for. The model supplies to, subject and text, so it can fix an over-length subject. It never supplies the top-level envelope, which your tool builds, so an unrecognized_keys 400 is your bug and no number of retries will clear it. Raising ModelRetry for a whole class of errors the model cannot influence just burns that tool’s retry counter until the run dies with UnexpectedModelBehavior. ToolFailed is the right exception for those, because it reports a terminal failure to the model without consuming the retry budget. Narrow ModelRetry to the fields the model owns and raise ToolFailed for everything else. For the outer loop, the codes worth branching on are in the developer surface: retryable is true only for rate_limited, provider_unavailable and temporarily_unavailable, and 409 idempotency_conflict means the same key was reused with different content, which the idempotency rules covers in full.

accepted is not delivered, and the union has to say so

queued is the beginning. What follows is a second state machine that your union must not flatten, because the single most expensive mistake an email agent makes is reporting provider acceptance as arrival.

StateEvent emittedWhat it proves
queuedmessage.queuedStored. No provider has been contacted.
acceptedmessage.acceptedOne provider took the submission.
failedmessage.failedA specific rejection was recorded.
submission_unknownmessage.submission_unknownAcceptance could not be confirmed.

All four come from one emit site, packages/domain/src/services.ts:4460, which fires message.${state} after the provider call settles. accepted means exactly one hop succeeded. Recipient outcomes arrive later, independently, and can disagree with each other: a message to five people has five delivery states. What each status actually proves works through the diagnosis order.

submission_unknown deserves its own branch rather than a fallthrough into failure. It is recorded when a submission is interrupted after the request goes out but before an acknowledgement returns, and the system deliberately stops instead of retrying. Round it down to “failed” in your union and a human presses resend on a message that already went out. Model it as what it is:

class Accepted(BaseModel):
    """A provider took the submission. This is not proof anyone received it."""
    state: Literal["accepted"]

class SubmissionUnknown(BaseModel):
    """Acceptance could not be confirmed. Do not resend. Escalate to a human."""
    state: Literal["submission_unknown"]

The event names, verbatim, and the provider vocabulary that is not yours

Eighteen event types are declared in packages/contracts/src/events.ts:

inbox.created  inbox.updated  inbox.deleted  domain.updated
message.received  message.queued  message.accepted  message.delivery_updated
message.failed  message.submission_unknown  message.canceled  message.quarantined
approval.requested  approval.approved  approval.rejected  approval.invalidated
webhook.test  project.sending_paused

Seven of the eighteen are declared and never emitted by this build: message.canceled, message.quarantined, approval.approved, approval.invalidated, inbox.updated, inbox.deleted and project.sending_paused. A webhook subscription accepts all seven and they simply never fire, which is the trap: your handler type-checks, your subscription succeeds, and the branch is dead code.

The approval pair is where this costs you. Only approval.requested and approval.rejected have emit sites (packages/domain/src/services.ts:1678 and :2094), so an agent blocking on a positive approval signal blocks forever. Approving the draft sends it, and what you observe is message.queued on the resulting message (:1965). Expiry is quieter still. Seven days after the request the approval stops working, and the next attempt to consume it returns HTTP 403 permission_denied with “Approval is expired, consumed, or no longer valid” (:1766). No event announces it, because approval.invalidated is declared and never fires. Poll the approval or watch the message, and treat the seven day window as a deadline your own code enforces. How the approval gate behaves covers the console side of the same object.

There is a second vocabulary that trips people up, and it is not yours. The upstream provider reports email.sent, email.delivered, email.bounced, email.complained, email.suppressed, email.delivery_delayed and email.failed. Those names never appear in your project event stream. They are translated at packages/domain/src/services.ts:4540 into a per-recipient status and surfaced as a single message.delivery_updated event carrying message_id, recipient and status. The mapping is direct: email.delivered to delivered, email.bounced to bounced, email.complained to complained, email.suppressed to suppressed, email.delivery_delayed to delayed, email.failed to failed. email.sent is the odd one out. It maps to no recipient status at all, so it produces no message.delivery_updated. The handler counts it as confirmation of acceptance, but that cannot rescue a submission_unknown send: callbacks are matched by the provider’s email ID, which an unknown send never recorded. Typing a tool against the email.* names is typing against a vocabulary the API does not speak, and the events and webhooks reference is the list to check before you write a handler.

Recipient status is ordered, which is the detail that makes it safe to model. The rank is pending 0, delayed 1, delivered 2, failed 3, bounced and suppressed 4, complained 5 (packages/domain/src/services.ts:4574), and an update is only applied when it ranks at or above the current value and is not older than the last observation. A late delivered webhook cannot overwrite a recorded bounce. Encode that as a Literal with a comparison helper and your agent can never narrate a regression it was never told about.

One more constraint decides how much you can promise a model here. A per-recipient update is only applied when the provider payload names exactly one recipient. An older aggregate payload listing several addresses cannot establish which one bounced, so the code deliberately matches no recipient and writes nothing, and no message.delivery_updated is emitted for any of them. Those recipients stay pending. So pending does not mean “in flight”, it means “no usable per-recipient report has arrived”, and a tool that renders it as “still sending” is guessing. Give the model the count of recipients still at pending alongside the states you do know, and let it say what it actually knows.

Making the model read the union instead of guessing at it

Pydantic AI does not send a tool’s return type to the model by default. The framework builds the tool schema from the function signature and the docstring. Parameters are typed, described and validated. The return annotation is used to validate and serialize what your code hands back, and then the model receives the serialized JSON with no schema attached.

So a discriminated union buys you real safety inside your process and nothing at all in the prompt, unless you do one of two things.

The cheap one is the docstring. Only three parts of it reach the model: the leading description, the parameter descriptions, and the first entry of the Returns section. Everything else Griffe can parse, including Raises, Notes and Examples, is dropped. That is why the Returns block in the tool above spells out both branches and ends by saying neither means delivered. It is the one place a return contract is guaranteed to land in the prompt.

The structural one is a capability:

from pydantic_ai.capabilities import IncludeToolReturnSchemas

agent = Agent(
    "openai:gpt-5.2",
    deps_type=Mailbox,
    capabilities=[IncludeToolReturnSchemas(tools=["send_email"])],
)

IncludeToolReturnSchemas puts the JSON schema of the return type into the tool definition. Models with native support for return schemas get it as a structured field; for everything else it is injected into the tool description as JSON text. Scope it to the tools that need it with the tools argument, because the union expands into a sizeable schema and paying that token cost on every tool is not worth it. With the capability on, the model sees the Literal["queued"] and Literal["approval_required"] tags and the fields that hang off each, which is the difference between a model that branches and a model that pattern-matches on English.

Connecting over remote MCP instead, and what arrives on that path

If you would rather not write the HTTP layer, the remote MCP endpoint is https://mcp.emailforagents.ai/mcp. Pydantic AI reaches it as an MCP client through MCPToolset, and the OAuth flow is a constructor argument:

from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset

mail = MCPToolset("https://mcp.emailforagents.ai/mcp", auth="oauth")
agent = Agent("openai:gpt-5.2", toolsets=[mail])

async def main():
    async with agent:
        result = await agent.run("Summarise anything new in the inbox.")
        print(result.output)

An API key will not work here. A key presented at /mcp is rejected by design and only an OAuth connection grant may call the tools. The grant is one of three presets, and the preset decides which of the six tools exist in the session: read gives list_inboxes, list_threads, list_messages and get_message; read_draft adds create_draft, which cannot transmit mail; read_draft_send adds send_message. The filter is visibleTools() at packages/mcp/src/index.ts:405, and tools/call re-checks the grant, so a read plus draft session is one in which send_message is absent from tools/list rather than merely refused on use. Hostile text in an inbound email cannot talk a model into calling a tool that was never in its schema. Why the tool surface is the permission boundary goes through the failure modes that survive.

create_draft is the same send path with the authority removed rather than a separate one. The handler rebuilds the principal with messages:send filtered out of its scopes and then calls the ordinary send service, so a draft runs every policy check a send runs and comes back as approval_required with reason_codes: ["draft_only_principal"] (packages/mcp/src/index.ts:330). That is worth knowing because it means a draft created over MCP can still be refused outright, for a blocked recipient or a disabled inbox, before it ever reaches the approval queue.

Three caveats specific to this route. The typed union does not come for free, because an MCP tool result arrives as a text content block containing JSON.stringify of the response body. You get a string, and if you want the union you parse it yourself, which is what process_tool_call on MCPToolset exists for.

A tool result over 131,072 characters is returned as an error, not as truncated content. The server serializes the response, measures the string, and if it exceeds that ceiling replaces the whole payload with isError: true and the text “Result exceeds the MCP text limit. Use the scoped API for the full body.” (packages/mcp/src/index.ts:339). A list_messages page with a large limit, or a get_message on a long HTML thread, hits it. This is the failure mode that punishes the default tool_error_behavior, which is 'retry': the model receives a ModelRetry, re-issues the identical call, gets the identical error, and grinds through the retry budget on something no rewording can fix. Set tool_error_behavior='failed' so the model sees a terminal result and routes around it, or 'error' so your own code catches it and refetches the body over REST. The cheaper fix is to not hit it, by passing a small limit to list_messages and following next_cursor.

One shared instance is one identity, so per-user credentials need a per-run toolset built with the @agent.toolset decorator and per_run_step=False, not a single long-lived object.

Should the tool call REST or MCP?

Both. The split that holds up in practice is by who owns the failure.

ConcernREST toolMCP toolset
Typed return at the boundaryNative, the union validates the bodyString, parse it in process_tool_call
Credentialefa_live_ or efa_test_ keyOAuth grant only, a key is rejected
Tool surfaceWhatever you writeFiltered by grant, send_message may not exist
Large bodiesPaginate and stream as you likeHard 131,072-character result ceiling
Per-user identityPass IDs through depsPer-run toolset, one instance is one identity
Time to first working callYou write the HTTP layerA constructor argument

The recommendation flips on one question. If the agent only reads and drafts, take MCP, because the grant removing send_message from the schema is a stronger guarantee than any check you will write, and the result sizes are small. If the agent sends, and especially if it has to reason about approval_required versus queued, write the REST tool, because that is the branch you want typed and in front of the model rather than recovered from a string. Running both against the same inbox is fine; the scoped grant and the scoped key are independent.

What this does not give you

A typed union describes outcomes. It does not produce them. Nothing above changes that approval is a human action with a seven day expiry, that live mail is sent and received on agents.emailforagents.ai while a test inbox uses a non-routable .mail.invalid address that cannot reach an internet recipient, or that the free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, where a unit is one message received or one recipient sent to.

Pydantic AI is not certified or otherwise formally endorsed here, and no such programme exists. It connects over remote MCP because it speaks remote MCP and OAuth, or over the REST API because the REST API is public. There is no published uptime SLA, no certification, and no independent audit of any of this. What there is instead is a strict request schema, a tagged response, an ordered per-recipient status and an event list you can check against source, which is enough to build a tool whose return type tells the truth.


Keep reading

All guides · Documentation · Integrations