Giving an OpenAI-based agent an email address
An agent built on OpenAI tooling can already call functions, so adding email is a question of what you point it at. There are two paths, and the one you take depends on whether your stack speaks MCP. Both end in the same place: an agent with its own address, its own key, and the same limits any other agent has.
4 min read
First, an identity of its own
Create an agent and a key from the quickstart, or let it provision its own. The key is scoped to one identity, so the agent can only send from the address it owns and read the mail addressed to it.
What you should not do is hand it OAuth credentials to a person's Gmail. The reasoning is in agent inbox or shared mailbox, and the short version is that a mailbox-scoped token cannot express per-agent revocation, attribution, or the difference between replying and starting a conversation.
Path one: MCP, where the tooling supports it
Where your stack can connect to an MCP server, point it at the endpoint and the whole tool surface arrives at once: send, reply, thread, search, wait, contacts, notes, vault. On the OpenAI Agents SDK that is one server object, attached to the agents you want to give an outbox.
import os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
async with MCPServerStreamableHttp(
name="mcpmailer",
params={
"url": "https://connect.mcpmailer.com/mcp",
"headers": {"Authorization": f"Bearer {os.environ['MCPMAILER_KEY']}"},
"timeout": 320,
},
cache_tools_list=True,
) as server:
agent = Agent(name="Support", mcp_servers=[server])
result = await Runner.run(agent, "Answer anything unread.")Two details that catch people. mcp_servers hangs off the Agent, not the run, so an agent you hand off to needs its own reference or it will not have the mail tools. And the timeout default is five seconds, which is shorter than wait_for_reply can legitimately block for, so raise it if you intend to wait in-band.
Nothing else to define, and nothing to keep in sync when the tool surface changes. The full picture is in what an MCP email server is, and the OpenAI Agents SDK page covers the client-specific detail.
Path two: function tools over REST
Where MCP is not available, define a small number of function tools and have them call the REST API with the same key. Resist the urge to expose everything: four tools cover most agents, and each extra one is another thing the model can get wrong.
const tools = [
{
type: 'function',
name: 'get_thread',
description: 'The whole conversation for a message, oldest first, quoted history removed.',
parameters: {
type: 'object',
properties: { message_id: { type: 'string' } },
required: ['message_id']
}
},
{
type: 'function',
name: 'lookup_contact',
description: 'What the workspace already knows about this address. Call before writing.',
parameters: {
type: 'object',
properties: { email: { type: 'string' } },
required: ['email']
}
},
{
type: 'function',
name: 'reply_all',
description: 'Reply in thread, keeping the audience. Use this rather than composing a new send.',
parameters: {
type: 'object',
properties: { message_id: { type: 'string' }, body: { type: 'string' } },
required: ['message_id', 'body']
}
},
{
type: 'function',
name: 'escalate',
description: 'Hand the thread to a human. Use when a rule fires or a claim cannot be sourced.',
parameters: {
type: 'object',
properties: { message_id: { type: 'string' }, why: { type: 'string' } },
required: ['message_id', 'why']
}
}
];Two design notes in that list. reply_all rather than a generic send, because an agent given a compose tool will eventually break the thread. And escalate as a real tool, because an escalation path the model can call is followed far more reliably than one described in prose.
Return refusals to the model, do not swallow them
The most common integration mistake: catching a rejected send and returning "error" to the model, which then tries again. Pass the reason through, and say in the description what it means.
const res = await fetch('https://mcpmailer.com/v1/messages', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(args)
}).then((r) => r.json());
// The model can act on this. "Something went wrong" it can only retry.
return res.status === 'rejected'
? { rejected: true, reason: res.reason, retry_after: res.retryAfter }
: { sent: true, message_id: res.messageId };daily_send_quota_exhausted and monthly_send_quota_exhausted carry a reset time, recipient_suppressed means that address already bounced or complained, and monthly_spend_cap_reached means only the workspace owner can lift it. An agent told what these mean waits or escalates; an agent that sees a generic failure loops.
Waiting, and the thing that does not fit in one call
Email conversations outlast a single run. Two mechanisms cover it, neither of which is a polling loop.
Inside a conversation the agent is having, wait_for_reply blocks server-side and returns when the reply arrives or the timeout expires. For anything arriving cold, take a message.received webhook and start a fresh run, rebuilding context with get_thread and lookup_contact. The patterns are in long-running email conversations and webhooks or polling.
Guardrails to set before it emails anyone
- Whitelist mode on the identity while you build trust, so strangers cannot reach it.
- A [system prompt](/blog/email-agent-system-prompt) with escalation triggers that fire without judgement.
- Untrusted text kept away from privileged tools. An inbound message is attacker-controlled input, per prompt injection by email.
- Verified [sending domain](/blog/dmarc-record-generator-agents) before anything goes to a customer.
- A person reading every thread in week one, which catches more than any test suite.
Questions
- Can a GPT-based agent send and receive email?
- Yes. Connect an MCP email server where your tooling supports MCP, or define a few function tools that call the REST API with the same scoped key.
- How many email tools should I expose?
- Four covers most agents: get the thread, look up the contact, reply in thread, and escalate. Every extra tool is another way for the model to do something you did not intend.
- Why `reply_all` instead of a send tool?
- Because a compose tool invites the model to start a new conversation instead of continuing one, which breaks threading for the recipient and for the agent's own later context.
- What should a tool return when a send is refused?
- The reason, verbatim, plus any retry-after. A generic error tells the model to retry, and retrying a quota refusal is how a temporary limit becomes a reputation problem.
- Should the agent use my Gmail account?
- Only if it genuinely acts as one specific person. Otherwise a dedicated identity gives you per-agent scope, attribution, and revocation that a mailbox token cannot.
- How does it handle replies that arrive tomorrow?
wait_for_replyinside an active conversation, and amessage.receivedwebhook for anything else, rebuilding context from the thread and contact record.
Give your agent an address it can answer from.
Create an inbox