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.readread, email.draftread_draft, email.sendread_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:

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.

StatusCodeWhat it means
400invalid_requestMalformed body, or a missing Idempotency-Key on a send. details.fields names the offending fields.
401unauthenticatedKey revoked or expired, or the authorising human’s access was removed.
402payment_required / quota_exceededNo entitlement or allowance left. Retrying will not fix it.
403permission_deniedThe credential lacks a scope, project or inbox.
403policy_deniedAn inbox, project or workspace policy blocked it. Check details.reason_codes.
403test_limit_exceededThree test inboxes, 100 retained test messages or 10 MiB already in use.
409idempotency_conflictSame key, different payload. Fix the key, not the payload.
412version_conflictThe draft or policy changed under you. Re-read before retrying.
422test_mode_unsupportedA live-only feature — a custom local part, a domain — on a test project.
429rate_limitedBack 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

All guides · Documentation · Integrations