Quickstart
Send your first agent email
One inbox, one credential, one request. Do it in a test project first: test inboxes are non-routable, so nothing you get wrong can reach a real person.
This is the shortest complete path through the agent email API. The reference behind it — every error code, every enforced limit, the full scope list — is on the documentation overview.
Before you start
- A workspace and a project. A project is either
testorliveand cannot be changed afterwards, so create a test one to begin with. - Decide the permission before you connect anything. Read, read + draft, and read + send are different blast radiuses. Read + draft cannot transmit mail at all.
- Know what free covers. Test mode allows 3 active inboxes, 100 retained messages and 10 MiB per workspace. The live allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity's first eligible workspace.
Create the inbox
Sign up, create a project, then create an inbox in it. What you get back depends on the project's environment.
Test project → a1b2c3d4-…@8e7f6a5b-….mail.invalid (generated, non-routable)
Live project → ada@agents.emailforagents.ai (real address)
Test addresses are generated. Asking for a custom local part or a domain on a test project
returns test_mode_unsupported — that restriction is what makes test mode safe
to point an unproven agent at. Your own domain is a
separate, later step and needs a paid subscription.
Give your agent access
Two credentials, and they are not interchangeable. An MCP client uses an OAuth connection grant; your own code uses a project-scoped API key.
https://mcp.emailforagents.ai/mcp - Open Access and paste that URL into your client's remote MCP server settings.
- Start the connection in that client. Sign in when the browser window opens.
- Check the client name, pick exactly which inboxes to share, and choose read, read + draft or read + send.
- Ask the client to list the inbox. Access then shows the grant and when it was last used.
The grant exposes six tools: list_inboxes, list_threads,
list_messages, get_message, create_draft and
send_message. A client only sees the tools its preset covers — a
read grant is not shown send_message at all, rather than being
shown it and refused. Grants expire after 30 days and can be revoked from Access at any
time.
An API key will not work at /mcp. An
efa_ key, and a signed-in console session, are both rejected there by design:
only a connection grant may invoke MCP tools. Remote MCP with OAuth is required, and no
named client has been certified — connect one and verify it yourself in a test project.
Or use the REST API
Create a named key in Access, save the one-time secret server-side, then list the inboxes it can see:
curl https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes \
-H "Authorization: Bearer $EFA_API_KEY"
This reads only; it sends nothing. The response is
data / next_cursor / has_more, the same shape as every
other list endpoint. Copy an inbox ID — it starts with ibx_ — into
EFA_INBOX_ID.
Send and watch it land
The send route is nested under both the project and the inbox, to is an array
of recipient objects, and the Idempotency-Key header is required.
curl -X POST \
"https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"to": [{ "email": "you@example.com", "name": "You" }],
"subject": "Hello from the agent",
"text": "First one."
}' HTTP/1.1 202 Accepted
{
"result": "queued",
"message_id": "msg_2f1c...",
"thread_id": "thd_9b04...",
"state": "queued",
"request_id": "req_7a3e..."
} queued means the request was accepted and a send intent exists — not that
anything has been delivered. The message moves queued →
submitting → accepted, and acceptance by the provider is still
not delivery. Watch the thread, or the
events stream, for what actually happened.
If the inbox requires review, or the credential is draft-only, you get
approval_required instead and nothing has been sent:
{
"result": "approval_required",
"draft_id": "drf_5c22...",
"draft_version": 1,
"approval_id": "apr_0e8d...",
"reason_codes": ["always"],
"request_id": "req_7a3e..."
}
Replaying the same Idempotency-Key with a byte-identical payload returns the
stored response rather than creating a second message, for 30 days. Replaying it with
different content returns idempotency_conflict — the key is bound to a hash of
the canonical payload, so editing the subject and retrying is a new send, not a retry. There
is one outcome you must not blind-retry: a submission whose result is unknown. See
idempotency keys for agent email.
Prove the inbound half
A one-way integration is half an integration. In a test project you can synthesise a received message without any real mail, which is enough to exercise your reply path, webhooks and approval flow end to end:
curl -X POST \
"https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/test/inbound" \
-H "Authorization: Bearer $EFA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbox_id": "ibx_8e7f6a5b-2c14-4a77-9d3e-0b51f7a2c8de",
"from": { "email": "buyer@example.com" },
"to": [{ "email": "a1b2c3d4-...@8e7f6a5b-....mail.invalid" }],
"subject": "Invoice 4821",
"text": "Could you resend the receipt?"
}'
That records an inbound message and emits message.received with
simulated: true. Then poll the project's event stream:
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=25" \
-H "Authorization: Bearer $EFA_API_KEY"
Persist next_cursor only after you have processed the events it covers. To
reply in-thread, pass the inbound message's ID as
reply_to_message_id on your send; the API sets
In-Reply-To and References from the stored parent so the reply
threads in the recipient's client.
Reading the errors
Branch on error.code rather than the HTTP status — several codes share a status.
The ones you are most likely to meet first:
| Code | HTTP | What to do |
|---|---|---|
unauthenticated | 401 | The key is wrong, revoked, or you sent a console token. Mint a fresh key. |
permission_denied | 403 | The key is missing a scope, or the project or inbox in the path is outside its grant. Check all three. |
policy_denied | 403 |
Policy blocked it. details.reason_codes says which rule:
send_disabled, blocked_recipient or
recipient_not_allowed. Retrying will not help.
|
payment_required / quota_exceeded | 402 | No entitlement or no allowance left. A retry cannot fix this either. |
test_limit_exceeded | 403 | You are at 3 test inboxes, 100 retained messages or 10 MiB. Delete something. |
invalid_request | 400 |
Usually a missing Idempotency-Key, or to sent as a string
instead of an array of objects. details.fields names the offenders.
|
idempotency_conflict | 409 | The same key with different content. Use a new key for new content. |
attachment_not_ready | 422 | A file has not passed its scan. See attachments. |
rate_limited | 429 | Back off. A retry-after: 60 header is sent. |
approval_required is not an error: it is a 202 response body meaning
the message is waiting for a human in Approvals.