Docs
Email for Agents documentation
Give an AI agent its own email address, then let it read, draft and send through a scoped credential you can revoke. This page is the shared reference the rest of the documentation builds on; every number below is read out of the implementation, not the marketing copy.
If you have not created an inbox yet, start with the two-minute quickstart. If you are deciding whether to build on the REST API or connect over MCP, the agent email API overview covers the product surface and MCP or API key for agent email compares the two credential models side by side.
Surfaces and base URLs
| Surface | Base URL | Who calls it |
|---|---|---|
| REST API | https://api.emailforagents.ai | Your own code, with a project-scoped API key |
| MCP server | https://mcp.emailforagents.ai/mcp | An MCP client holding an OAuth connection grant |
| Console | https://app.emailforagents.ai | A signed-in human: inboxes, approvals, keys, grants, policy |
The API version string is 2026-09-19. It appears on every event record and on every
webhook body, so a consumer can branch on it rather than guessing from the payload shape.
Authenticating a request
There are two machine credentials and they are not interchangeable. Which one you hold decides which surface you may call.
Project-scoped API keys
Create a key in Access. The plaintext is shown once. Its shape is
efa_test_<8 hex>_<43 base64url characters> for a test project and
efa_live_… for a live one, so the environment is legible in a log line without
resolving the key. Send it as Authorization: Bearer efa_live_…. Only the digest is
stored, which is why a lost key must be replaced rather than recovered. Revoke one with
POST /v1/keys/{key_id}/revoke.
Connection grants for MCP
MCP clients authenticate with an OAuth connection grant, obtained through a browser consent
step in which a human picks the specific inboxes and the permission preset. An
efa_ API key presented at /mcp is rejected, and so is a console
session token: only a principal of type connection may invoke MCP tools. That is
deliberate, not a gap — an MCP client is software a human authorised for named inboxes, and the
grant carries that authorisation. The server identifies itself as
email-for-agents-mcp 1.0.0 and negotiates protocol versions
2025-11-25, 2025-06-18 and 2025-03-26. A
GET to the endpoint returns 405 with Allow: POST.
No MCP client is certified or one-click here. Connections are recorded with a
trust class of unverified unless an operator has classified them otherwise, and
no client has been through a verification process. Connect one, then prove a round trip in a
test project before you rely on it.
Scopes and connection presets
Authority is re-derived on every request and intersected with the authorising human's current
grant, so demoting a member or revoking a key narrows what an existing credential can do without
any cache to wait for. There are 32 scopes; sending uses messages:send, submitting
a draft for review uses drafts:submit, and reading events uses
events:read.
MCP consent offers three presets, and each maps to a fixed scope set:
| Preset | OAuth scope | Scopes granted |
|---|---|---|
read | email.read | inboxes:read, threads:read, messages:read,
attachments:read, events:read |
read_draft | email.draft |
the read set plus drafts:read, drafts:write,
drafts:submit |
read_draft_send | email.send | the read_draft set plus messages:send |
Administrative scopes can never be attached to a connection: keys:manage,
members:manage, billing:manage, policies:write,
inboxes:delete, messages:delete, messages:raw and
exports:write are refused on a grant. A connection holding only
drafts:submit is treated as draft-only: its sends become approval requests even
when the inbox permits direct sending. See
sending controls and approvals for how that interacts with
inbox policy.
Resource ID prefixes
Every identifier is a prefix plus a UUID, and the prefix is validated on input. Passing a thread ID where an inbox ID belongs fails immediately rather than resolving to something plausible.
| Prefix | Resource | Prefix | Resource |
|---|---|---|---|
ws_ | Workspace | prj_ | Project |
ibx_ | Inbox | thd_ | Thread |
msg_ | Message | drf_ | Draft |
apr_ | Approval | att_ | Attachment |
upl_ | Upload | dom_ | Domain |
key_ | API key | con_ | Connection |
evt_ | Event | wh_ | Webhook endpoint |
exp_ | Export job | del_ | Deletion job |
Inbox IDs use ibx_. Most routes are nested under both the project and the inbox —
for example POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages — because
a credential is scoped to projects and inboxes, and the path is what that scope is checked
against.
Request and response rules
- Sends require an
Idempotency-Keyheader. Without one the request is rejected withinvalid_requestbefore anything is created. See idempotency keys for agent email for the replay semantics. - JSON bodies are capped at 1 MiB and read through a bounded reader, so an
oversized body is refused with
payload_too_largerather than buffered. Attachment content uploads are capped at 10 MiB instead. - List endpoints return
data,next_cursorandhas_more. The default page is 25 items andlimitaccepts 1 to 100. A thread's message list defaults to 50. Cursors are covered in search and pagination. - A successful send returns HTTP 202 with
result: "queued", orresult: "approval_required"when policy or a draft-only credential routes it to review. Neither means the message left the building yet. - Every error shares one envelope and carries a request ID you can quote in a support thread.
{
"error": {
"code": "policy_denied",
"message": "Workspace policy blocked this send.",
"request_id": "req_...",
"retryable": false,
"details": { "reason_codes": ["blocked_recipient"] }
}
} retryable is computed, not decorative: it is true for
rate_limited, provider_unavailable and
temporarily_unavailable, and false everywhere else unless the specific call sets it.
Retrying a non-retryable error changes nothing except your rate-limit budget.
Error codes and HTTP statuses
The whole set, with the status each one maps to. Branch on error.code, not on the
status: several codes share a status and mean different things.
| Code | HTTP | What it means |
|---|---|---|
invalid_request | 400 | The body or headers do not match the schema. |
invalid_cursor | 400 | The cursor is malformed or belongs to another filter. |
cursor_expired | 410 | The events you were paging through are no longer retained. Restart without a cursor. |
unauthenticated | 401 | Missing, malformed or revoked credential. |
payment_required | 402 | The action needs an entitlement this workspace does not have. |
quota_exceeded | 402 | A plan quota, such as the custom-domain count, is used up. |
permission_denied | 403 | The credential lacks the scope, project or inbox for this call. |
policy_denied | 403 | Inbox, project or workspace policy blocked the send. Read details.reason_codes. |
daily_limit_exceeded | 403 | The daily recipient cap for this inbox is exhausted. |
test_mode_required | 403 | A simulation endpoint was called against a live project. |
test_limit_exceeded | 403 | A test-mode cap would be exceeded: 3 active inboxes, 100 retained messages or 10 MiB. |
not_found | 404 | No such resource, or none you are authorised to see. |
source_unavailable | 404 | The underlying content could not be read. |
idempotency_conflict | 409 | The key was reused with a different canonical payload. |
idempotency_expired | 409 | The 30-day replay window for that key has closed. |
address_unavailable | 409 | The requested address is taken or reserved. |
send_already_submitting | 409 | A send with this key is in flight. |
last_owner_required | 409 | The change would leave the workspace with no owner. |
version_conflict | 412 | Someone else changed the policy, endpoint or draft. Re-read and resubmit. |
payload_too_large | 413 | Body, upload or attachment set exceeds its limit. |
recipient_suppressed | 422 | A recipient is on the suppression list after a hard bounce or complaint. |
domain_not_ready | 422 | Ownership, sending or receiving verification has not completed. |
attachment_not_ready | 422 | A file has not finished a successful scan. |
test_mode_unsupported | 422 | The action does not exist in test mode, such as attaching a domain. |
reserved_address | 422 | The local part is reserved by the platform. |
environment_immutable | 422 | A project cannot move between test and live. |
rate_limited | 429 | Too many requests. A retry-after: 60 header is sent. |
provider_unavailable | 503 | The upstream mail provider could not be reached. |
temporarily_unavailable | 503 | The service, or a dependency it needs to stay safe, is unavailable. |
Limits enforced in code
These are checked on the request path, not published as guidance. Exceeding one returns an error rather than truncating your input.
| Limit | Value |
|---|---|
| JSON request body | 1 MiB |
text and html body | 512,000 characters each |
| Subject | 998 characters, and no CR or LF |
| Unique recipients per message (To + Cc + Bcc) | 25 |
| Attachments per message | 10 |
| Bytes per attachment | 10 MiB |
| Aggregate attachment bytes per message | 15 MiB |
| Inbound raw MIME accepted | 25 MiB |
| List page | 25 by default, 100 maximum |
| Recipient allow or block entries per list | 100 |
| Idempotency replay window | 30 days |
| Idempotency tombstone | 365 days |
| Approval expiry | 7 days |
| Reserved upload expiry | 24 hours |
| Minimum interval between webhook test events | 60 seconds |
| Active test inboxes per workspace | 3 |
| Retained test messages per workspace | 100 |
| Retained test bytes per workspace | 10 MiB |
| Free live storage per workspace | 100 MiB |
The free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity's first eligible workspace. Creating a second workspace does not renew it. Pricing has the full wording.
Rate limits
Three counters run in production: 1,200 requests per 60 seconds per client IP at the edge, 600
per 60 seconds per principal, and 600 per 60 seconds per workspace. Exceeding any of them
returns rate_limited with retry-after: 60.
Treat those numbers as a ceiling rather than a contract. The counters are Cloudflare rate-limit bindings, which are local to the edge location serving the request and deliberately permissive; the code that reads them says as much. They are protection against a runaway loop, not a metering system, and they are never billing authority.
Test mode and live mode
A project is created as test or live and cannot move between them; the
attempt returns environment_immutable. The environment decides the transport.
| Test project | Live project | |
|---|---|---|
| Address | Generated, on a non-routable .mail.invalid host | A real address on agents.emailforagents.ai or your own verified domain |
| Transport | A local fake provider. Nothing reaches the internet. | The upstream mail provider |
| Inbound | POST /v1/projects/{project_id}/test/inbound synthesises a received message | Real mail delivered to the address |
| Caps | 3 active inboxes, 100 retained messages, 10 MiB | Entitlement, storage and daily recipient caps |
| Custom domains | Not supported | Requires a paid subscription |
Test inboxes accept no custom local part or domain: the address is generated, and asking for one
returns test_mode_unsupported. That is what makes test mode safe to point an
unproven agent at. Testing an agent's email without sending real mail walks through a full rehearsal.
What is not available yet
- Paid checkout is disabled. Builder and Team are proposed prices and cannot be
bought today. Anything gated behind a paid subscription — custom domains, higher quotas — is
therefore gated in practice too.
- No published SDK. There is no package to install; the examples here are curl
and plain HTTP on purpose.
- No certified MCP client. Generic remote MCP with OAuth is the supported shape;
no named client has been verified.
- Customer-triggered event replay is not exposed. Recover missed events with the
durable events cursor instead.
- Several event types are declared but not emitted by this build. The
event catalogue marks which is which, so you
do not build a branch that never runs.
- There is no published uptime SLA and no status feed. Provider acceptance is
also not proof of delivery; see status and
security.
Where to go next
Quickstart
Create an inbox, connect an agent, send your first message.
Sending controls & approvals
Recipient allow-lists, pause, daily caps, human review.
Events & webhooks
The event catalogue, signature verification, retry schedule.
Attachments
Reserve, upload, scan, send and download files safely.
Search & pagination
What the index covers and how signed cursors behave.
Custom domains & DNS
The exact TXT, MX, SPF and DKIM records, and why order matters.
Exports & data controls
NDJSON exports, their exclusions, deactivation versus erasure.
Email for AI agents
The conceptual guide behind all of the above.