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 test or live and 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.
1

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.

Create an inbox

2

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
  1. Open Access and paste that URL into your client's remote MCP server settings.
  2. Start the connection in that client. Sign in when the browser window opens.
  3. Check the client name, pick exactly which inboxes to share, and choose read, read + draft or read + send.
  4. 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.

3

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.

4

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 queuedsubmittingaccepted, 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.

5

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:

CodeHTTPWhat 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.

Next steps

Put a human in front of the first live send with recipient allow-lists and approvals, then stop polling and verify signed webhooks instead. When files are involved, read how attachments are scanned before they can be sent.

Longer form: the agent email API loop, from send to reply and how to give an AI agent its own email address.