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.
| State | Event emitted | What it proves |
|---|---|---|
queued | message.queued | Stored. No provider has been contacted. |
accepted | message.accepted | One provider took the submission. |
failed | message.failed | A specific rejection was recorded. |
submission_unknown | message.submission_unknown | Acceptance 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.
| Concern | REST tool | MCP toolset |
|---|---|---|
| Typed return at the boundary | Native, the union validates the body | String, parse it in process_tool_call |
| Credential | efa_live_ or efa_test_ key | OAuth grant only, a key is rejected |
| Tool surface | Whatever you write | Filtered by grant, send_message may not exist |
| Large bodies | Paginate and stream as you like | Hard 131,072-character result ceiling |
| Per-user identity | Pass IDs through deps | Per-run toolset, one instance is one identity |
| Time to first working call | You write the HTTP layer | A 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
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown: what each email status actually proves, what it does not, and the correct next action for every one of them.
The MCP Tool Surface Is the Permission Boundary
How the MCP tool surface permission boundary is computed per request, what a filtered tools/call returns, and three ways the tool list shrinks under you.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.