Integrations · September 21, 2026
How to Give an AI Agent Its Own Email Address
Create a dedicated inbox, choose read, draft or send permission, connect over MCP or a scoped key, and prove a real round trip before granting send access.
This is the setup path, start to finish: create the inbox, decide what the agent may do before you connect it, connect it, prove a real round trip, and revoke the credential when you are done. It takes about twenty minutes, most of which is waiting for mail.
If you want the conceptual picture first — what an agent inbox is made of and why a borrowed human mailbox is a bad substitute — read how agent inboxes actually work. This page assumes you have decided and want the steps.
What you need first
Three things, and one decision. The API-reference version of the same ground is in the quickstart.
You need a workspace and a project. Projects carry an environment — test or live —
and that environment is fixed at creation. There is no promotion; a test project never
becomes a live one. Create both up front: a test project to build against and a live
project for the real thing.
You need to know where your agent runs. If it runs inside an application that supports remote MCP servers with OAuth, the agent’s owner connects it through a browser consent screen. If it runs in code you deploy, you mint a scoped API key and put it in your secret store. These are covered separately below.
You need somewhere to send test mail from — an ordinary mailbox you control, for the live round trip. In test mode you will simulate inbound mail instead.
The decision is the permission level, and it comes before the connection because it is awkward to narrow afterwards.
Step 1: create the inbox
In the console, pick the project and create an inbox with a display name. Over the API:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes" \
-H "Authorization: Bearer $EFA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"display_name":"Support triage","local_part":"support-triage"}'
A live project on the platform domain returns an address on
agents.emailforagents.ai. A test project ignores local_part — in fact it rejects
it, with HTTP 422 and test_mode_unsupported, because test addresses are generated and
non-routable. A test inbox comes back on .mail.invalid, the domain RFC 6761 reserves
precisely so that it can never resolve.
Two limits to know here. Test projects allow three active inboxes per workspace; the
fourth returns HTTP 403 with test_limit_exceeded. And some local parts are reserved
on the platform domain — support, billing, admin, postmaster, abuse and
similar — which return HTTP 422 with reserved_address. Pick a name that describes the
job: invoice-intake, support-triage, scheduling.
The response carries the inbox ID you will need everywhere else:
{
"id": "ibx_9a1f...",
"address": "support-triage@agents.emailforagents.ai",
"display_name": "Support triage",
"project_id": "prj_...",
"environment": "live",
"domain_kind": "platform",
"status": "active"
}
Step 2: decide the permission before you connect
Three presets. Pick the narrowest one that lets the agent do its job today.
read grants inboxes:read, threads:read, messages:read, attachments:read
and events:read. The agent can look at mail. Nothing can leave. This is the right
level for summarisation, triage-and-label, and anything where a human writes the reply.
read_draft adds drafts:read, drafts:write and drafts:submit. The agent
composes a complete, sendable message — recipients, subject, body, attachments — and
submits it, and it lands in a human approval queue. This is enforced, not a convention:
a principal with drafts:submit and without messages:send gets approval_required
back with the reason code draft_only_principal, even if the inbox itself is set to
allow direct sending.
read_draft_send adds messages:send. The agent transmits directly, subject to
whatever the inbox policy still requires.
Start at read_draft. You get the agent’s real output, in its real format, with a real
recipient list, and you see it before anyone else does. When you have reviewed enough
of them to be bored, widen the credential.
Whatever you choose, some scopes are never available to an agent connection at all:
keys:manage, members:manage, billing:manage, policies:write, inboxes:delete,
messages:delete, messages:raw and exports:write. An agent cannot mint itself a
wider key, rewrite its own policy, or delete the record of what it did.
Step 3a: connect an MCP client
If your agent lives in an application that speaks remote MCP, add
https://mcp.emailforagents.ai/mcp as a remote MCP server. The client discovers the
authorization server through /.well-known/oauth-protected-resource, and the flow is
authorization code with S256 PKCE — the only flow the server accepts.
The client asks for one of three OAuth scopes, which map onto the presets above:
email.read → read, email.draft → read_draft, email.send → read_draft_send.
In the browser you sign in, then choose the project, tick exactly which inboxes to
share, and pick the preset. Two rules apply to that choice. You cannot grant more than
the client asked for, and you cannot grant more than you yourself hold — if your own
role lacks messages:send, you cannot hand it to an agent.
A few properties worth knowing before you approve anything:
- The authorization transaction is bound to the browser that started it and to the account that signs in, and it expires after ten minutes. If you start a connection on your laptop and finish it on your phone, it fails. That is intentional.
- Grants expire after 30 days. They do not silently renew forever.
- No named MCP client is certified here.
client_trust_classis recorded asunverifiedfor every client today. The client name on the consent screen is a string the client supplied about itself; read it as a claim, not as an identity.
Once connected, the tool list you see depends on the scopes in your grant. A tool is
only advertised if the grant holds every scope that tool requires — so a read grant
sees four tools, read_draft sees five, and read_draft_send sees six.
The MCP email server guide goes through the surface in
detail.
Step 3b: or mint a scoped API key
If you own the code, create a named key in the console, scoped to one project and, if possible, to specific inboxes. The secret is shown exactly once.
Key format is efa_test_ or efa_live_, eight hex characters, then a 43-character
secret. The environment is carried in the prefix and is enforced: a key cannot cross
between test and live projects, whatever you point it at.
Two more properties that are easy to miss:
A credential cannot grant permissions its creator does not hold. Requesting a scope
your own role lacks returns permission_denied at creation time, not at use time.
Authority is re-read from the database on every single request, not trusted from the token. If the human who authorised a key loses a role or a project assignment, every key they created narrows immediately, without anyone revoking anything. There is no window in which a stale token keeps working.
Use it by listing inboxes, which sends nothing:
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes" \
-H "Authorization: Bearer $EFA_API_KEY"
Step 4: email the new address from a mailbox you control
In a live project, open your own mail client and send a short message to the new address. Subject and body do not matter; you are testing routing.
In a test project you cannot receive real mail, so create the inbound message instead:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/test/inbound" \
-H "Authorization: Bearer $EFA_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbox_id": "ibx_...",
"from": {"email": "customer@example.com", "name": "Dana Okafor"},
"to": [{"email": "support-triage@example.invalid"}],
"subject": "Invoice 4471 looks wrong",
"text": "The October line item is duplicated. Can you check?"
}'
This needs the test:simulate and messages:read scopes, and it only works on a test
project with a test inbox — anything else returns HTTP 403 with test_mode_required.
It emits a genuine message.received event carrying "simulated": true, so your
event handler runs the same code path it will run in production.
Either way, confirm arrival before you go further. Poll the event stream:
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=50" \
-H "Authorization: Bearer $EFA_API_KEY"
You are looking for message.received. If it is not there, nothing downstream will
work and there is no point debugging the agent.
Step 5: have the agent read it and draft a reply
Now ask the agent to do the thing. Over MCP, that is a list_messages call followed by
get_message and then create_draft. Over REST it is a GET on the inbox messages
collection, a GET on the message body, and a POST to the messages endpoint with a
read_draft credential.
The reply should thread. Pass the inbound message’s ID as reply_to_message_id, and
supply recipients and subject explicitly:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_DRAFT_KEY" \
-H "Idempotency-Key: reply-invoice-4471-v1" \
-H "Content-Type: application/json" \
-d '{
"to": [{"email": "customer@example.com"}],
"subject": "Re: Invoice 4471 looks wrong",
"reply_to_message_id": "msg_...",
"text": "Thanks — I can see the duplicate October line. A corrected invoice is on its way."
}'
Note the shape: to is an array of objects, not a string, and Idempotency-Key is
mandatory. Omitting it returns HTTP 400 with invalid_request.
With a read_draft credential the response is HTTP 202 and:
{
"result": "approval_required",
"draft_id": "drf_...",
"draft_version": 1,
"approval_id": "apr_...",
"reason_codes": ["draft_only_principal"],
"request_id": "req_..."
}
Nothing has been sent. An approval.requested event has been emitted.
Step 6: approve the first send yourself
Open the approval in the console. You see the exact recipients, the subject and the sanitised body — the same bytes that will go out, not a summary. Approve it, or reject it with a note.
Approvals are bound to content. The approval record carries the hash of that exact
canonical payload, so if the draft is modified after review, the approval no longer
matches and the send is refused with version_conflict. It is not possible to approve
one message and have a different one transmitted.
Approvals expire after seven days. And approving is itself idempotent — the approve
endpoint requires its own Idempotency-Key — so a double-clicked button cannot produce
two emails.
Once approved, the message enters queued, then accepted when the provider takes it.
accepted means the provider acknowledged the submission. Per-recipient delivery
arrives separately, as message.delivery_updated.
Accepted is not delivered, and an interface that
conflates them will mislead whoever is on call.
Step 7: revoke the credential when the experiment ends
Do this even for a prototype, because the prototype credential is the one that is still live in six months.
For an MCP connection, revoke the grant:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/connections/$EFA_CONNECTION_ID/revoke" \
-H "Authorization: Bearer $EFA_API_KEY"
For an API key:
curl -X POST "https://api.emailforagents.ai/v1/keys/$EFA_KEY_ID/revoke" \
-H "Authorization: Bearer $EFA_API_KEY"
Then confirm it took. Make one more call with the revoked credential and check you get HTTP 401. Because authority is re-read on every request rather than trusted from the token, revocation takes effect immediately — but “should” and “did” are different claims, and this one costs you a single curl.
Reading the errors
The error body is always the same envelope:
{"error":{"code","message","request_id","retryable","details"}}. The code is the
part to branch on. retryable is true only for rate_limited,
provider_unavailable and temporarily_unavailable.
| Status | Code | What it means |
|---|---|---|
| 400 | invalid_request | Malformed body, or a missing Idempotency-Key on a send. details.fields names the offending fields. |
| 401 | unauthenticated | Key revoked or expired, or the authorising human’s access was removed. |
| 402 | payment_required / quota_exceeded | No entitlement or allowance left. Retrying will not fix it. |
| 403 | permission_denied | The credential lacks a scope, project or inbox. |
| 403 | policy_denied | An inbox, project or workspace policy blocked it. Check details.reason_codes. |
| 403 | test_limit_exceeded | Three test inboxes, 100 retained test messages or 10 MiB already in use. |
| 409 | idempotency_conflict | Same key, different payload. Fix the key, not the payload. |
| 412 | version_conflict | The draft or policy changed under you. Re-read before retrying. |
| 422 | test_mode_unsupported | A live-only feature — a custom local part, a domain — on a test project. |
| 429 | rate_limited | Back off. The response carries retry-after: 60. |
An approval_required result is not an error and does not use this envelope. It is a
successful HTTP 202 with a different result value.
Do this in a test project first
Everything above works against a test project, with simulated inbound mail, real events, real approvals and real error codes — and addresses that physically cannot reach the internet. The first message an agent sends to a real person should not be the first message it has ever composed.
Test an agent’s email without sending real mail walks the sandbox path and the pre-live checklist. If you are building the integration in code rather than connecting a client, the full API loop is the next thing to read.
Keep reading
Email for AI Agents: How Agent Inboxes Actually Work
What an email inbox for an AI agent is, how mail arrives and leaves, which permissions matter, and when an agent needs its own address instead of yours.
MCP Email Server: Give Your Agent Client a Real Inbox
How a remote MCP email server works, what the consent screen must show, how to scope a grant to one inbox, and how to verify it before allowing sends.
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.