Inboxes · September 26, 2026
How an AI Agent Actually Reads Its Inbox
How to let my agent read its email: list threads, page messages, fetch a body, and the ordering rules that decide if it sees a reply before it acts.
A customer emails your agent asking for a refund on order 1182. Forty seconds later they send a second message: “Sorry, wrong order, it’s 1183.” Your agent lists the inbox, takes the first message it recognises as a refund request, and refunds 1182. Both messages were in the response it read. It simply read them in the wrong order, and nothing in the API call was wrong.
Most write-ups on how to let my agent read its email give the read path one paragraph:
list messages, fetch one, done. The short answer is different: give the agent a
credential with threads:read and messages:read, find conversations with the threads
list, read each conversation through its own thread route (oldest first), and fetch
only the bodies it needs. This piece covers the whole of it: four routes, the scope each
one checks, the order each one returns, how its cursors behave, and what comes back when
you ask for a body. If the question is how the agent finds out that mail arrived at all,
that is a different decision, covered in
polling vs webhooks for agent email. This piece
starts once you know there is something to read.
Every behaviour below is what the current API and MCP server code does, including the places where it differs from the published OpenAPI spec.
How to let my agent read its email: four routes, three jobs
The read surface over REST is exactly these paths under https://api.emailforagents.ai/v1:
GET /projects/{project_id}/inboxes/{inbox_id}/threads
GET /projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages
GET /projects/{project_id}/inboxes/{inbox_id}/messages
GET /projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body
That is four routes for three jobs. Finding a conversation is the threads list. Reading a conversation is the thread’s messages. Reading one message’s content is the body. The fourth route, the inbox-wide messages list, is a flat feed of every message in the inbox regardless of thread, and it is the one most agents reach for first and the one that produces the refund bug above.
Over MCP the same pipeline is exposed as three tools: list_threads, list_messages and
get_message, plus list_inboxes to discover inbox IDs. They are not a one-to-one map
of the REST routes, and the gap matters; it gets its own section below.
Two identifiers do all the navigation. A thread ID starts thd_, a message ID starts
msg_, an inbox ID starts ibx_. A thread ID is only ever used to page a thread. A
message ID is what you pass to the body route, to get_message, and to
reply_to_message_id when the agent answers. Neither is the RFC Message-ID header,
which the REST API does not return at all;
how threading actually works explains why that header
is internal and how inbound mail is grouped into a thread in the first place.
Which scope each read call checks
Each route checks one scope, and it is not the one the path suggests for the thread messages route.
| Call | REST route | MCP tool | Scope checked |
|---|---|---|---|
| Find conversations | .../inboxes/{inbox_id}/threads | list_threads | threads:read |
| Read one conversation, in order | .../threads/{thread_id}/messages | none | messages:read |
| Flat feed of the inbox | .../inboxes/{inbox_id}/messages | list_messages | messages:read |
| One message’s content | .../messages/{message_id}/body | get_message | messages:read |
The thread messages route checks messages:read, not threads:read. So a credential
holding only threads:read can list conversations, see subjects, previews and counts,
and cannot open a single message in any of them. A credential holding only
messages:read can read every message, and can page a thread in order as long as it
already has the thd_ ID, which every message object carries in thread_id. For an
agent that reads, grant both; the full read-only set is five scopes and
read-only email access for an AI agent
lists them with the ways that grant quietly widens.
messages:read is the scope that reads mail. messages:raw does not read anything.
It is declared in the scope list and granted to the developer role, and no handler
checks it. There is no raw MIME route to unlock: every message object and every body
response carries original_raw_available: false, set as a constant rather than looked
up. Adding messages:raw to an agent key buys no additional content. It is also on the
list of scopes a connection grant may not carry, so it cannot reach an MCP session.
All four routes also check that the project and inbox are inside the credential’s
allowance before touching the database. An inbox-scoped key asking about a sibling inbox
gets not_found with “Resource not found.”, not permission_denied and not an empty
list. The API does not confirm that an inbox outside your grant exists, so a not_found
on a read is worth checking against the key’s inbox_ids before you go hunting for a
deleted inbox.
What each list actually returns
The threads list is deliberately thin. Each row is:
{
"id": "thd_...",
"inbox_id": "ibx_...",
"subject": "Refund for order 1182",
"preview": "Sorry, wrong order, it's 1183.",
"message_count": 2,
"last_message_at": "2026-09-26T09:14:40.512Z"
}
subject is the thread’s display subject, fixed from its first message and never
rewritten. preview is the preview of the thread’s latest message by sequence, which
means the row that looks like “a refund request for 1182” can already be carrying the
correction. There are no recipients, no sender and no message IDs on a thread row. To act
on anything, the agent has to open the thread.
Message objects, from either messages route, are much wider:
{
"id": "msg_...",
"thread_id": "thd_...",
"inbox_id": "ibx_...",
"direction": "inbound",
"subject": "Re: Refund for order 1182",
"preview": "Sorry, wrong order, it's 1183.",
"state": "received",
"environment": "live",
"simulated": false,
"from": { "email": "sam@example.com", "name": "Sam" },
"to": [{ "email": "support@agents.emailforagents.ai" }],
"cc": [],
"bcc": [],
"reply_to": [],
"recipients": [
{ "email": "support@agents.emailforagents.ai", "type": "to", "delivery_status": "delivered" }
],
"attachments": [],
"created_at": "2026-09-26T09:14:40.918Z",
"sequence": 2,
"delivery_status": "delivered",
"body_available": true,
"original_raw_available": false
}
Two fields are present only on the thread messages route: sequence, the message’s
position in its thread, and delivery_status, the status of a To recipient (with several,
read recipients instead, which has one entry each). The inbox-wide list omits both. bcc is populated only for outbound messages; an inbound
message never exposes Bcc recipients, even ones the inbox saw. Each attachment carries
id, filename, bytes, scan_status, content_type, disposition, content_id and a
downloadable boolean that is true only when the scan is clean.
preview is the first 240 characters of the text part with whitespace collapsed. When a
sender sent HTML only, it is cut from the sanitized HTML instead, so expect markup in it.
Do not treat a preview as a summary of the message, and do not make a decision from one;
it is there so the agent can decide which body is worth fetching.
Both lists include the agent’s own outbound messages, with direction: "outbound" and
the send’s current state, which can be queued before anything has reached a
provider. A thread the agent has just replied into will show that reply as its newest
message. Filter on direction before you decide whether the other side has spoken last.
Why the inbox feed shows the reply before the parent
The inbox-wide list is newest first. The thread messages list is oldest first. This single asymmetry decides whether your agent sees the correction before or after the thing it corrects.
| Route | Sort key | Direction | Default limit |
|---|---|---|---|
.../threads | last_message_at, then ID | Newest first | 25 |
.../inboxes/{inbox_id}/messages | created_at, then ID | Newest first | 25 |
.../threads/{thread_id}/messages | sequence, then ID | Oldest first | 50 |
An agent that pages the inbox feed and handles messages in array order handles the correction first, then the original request, then acts on the original because it was the last thing it read. Reversing the page does not fix it in general, because the parent can be on the next page. The fix is structural: use the feed, or better the event stream, to learn which threads have something new, then read each of those threads through its own route, where order is by sequence.
Three details about those sort keys are worth knowing before you rely on them.
sequence is assigned when the message is stored, by incrementing a counter on the
thread row inside the same transaction. It is the order messages entered this inbox, not
the order the sender wrote them and not the Date header. A reply that arrived before
its parent’s Message-ID was known can be held and retried for up to ten minutes, as the
threading article describes, and when it lands it takes the next sequence number at that
moment. Sequence is still the only order you should act on, because it is the order the
system itself used.
created_at is the time the row was written. It is not returned alongside a received
time, and nothing in the code forces created_at order to agree with sequence order
for two messages stored concurrently into one thread, because they are assigned by
different mechanisms. If you rebuild a thread from the inbox feed, which is the only
option over MCP, sort by created_at and accept that this is close to, not identical
to, the thread’s own order.
last_message_at on a thread moves every time a message joins it. For inbound mail it
takes the provider’s received timestamp and never moves backwards; for an outbound send
it takes the time of the send. A thread the agent replies to jumps to the top of the
threads list, which is convenient for a human console and a trap for an agent that
treats “top of the list” as “someone needs an answer”.
The published OpenAPI spec lists a default limit of 25 for all three list routes. The
handler for thread messages defaults to 50. Pass limit explicitly and the difference
never reaches you.
Cursors are for one pass, not for “since last time”
All three lists page with the same cursor, and it behaves nothing like the event cursor.
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/threads?limit=25" \
-H "Authorization: Bearer $EFA_API_KEY"
{ "data": [ ... ], "next_cursor": "eyJ2IjoxLCJiaW5kaW5nIjoi....3f9c...", "has_more": true }
limit is 1 to 100; anything else is invalid_request, not a silent clamp. has_more
is exact, computed by fetching one row past the limit. The cursor is opaque and signed,
and four properties follow from how it is built.
It is bound to a filter. The signature covers the workspace, project, inbox, which list
it came from, and the search query q. A cursor from one thread’s messages is rejected
on another thread, and a cursor from q=refund is rejected on q=invoice. limit is
not bound, so you can change page size between pages.
It expires 24 hours after it was issued. An agent that pauses mid-scan, waiting on a
human approval overnight for instance, comes back to a 400 invalid_cursor with
“Cursor is invalid, expired, or belongs to another filter.” There is no separate expiry
error to branch on. Restart the scan from the first page.
It is null on the last page. When has_more is false, next_cursor is null, so you
cannot store the tail of a thread and ask later for anything after it. To see whether a
thread has grown, read it again. With the thread route at up to 100 messages a page,
that is one request for nearly every real conversation.
It is a keyset position, not an offset. Pages never repeat a row and never shift when
new mail arrives, with one consequence on the threads list: a thread that receives a
message while you are paging moves above your cursor, so if you had not reached it yet,
this pass skips it. It is not lost; it will be at the top of the next pass, and its
message.received event is on the event stream regardless. What you must not assume is
that one full pass of the threads list is a consistent snapshot of the inbox.
Search, the q parameter, only runs on the threads list and the inbox feed. The thread
messages route ignores it, so a q there returns the whole thread rather than an
error. Matching, tokenisation and what the index covers are in
the search and pagination docs.
Fetching a body: what text and html contain
The body route returns four fields and nothing else:
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages/$MESSAGE_ID/body" \
-H "Authorization: Bearer $EFA_API_KEY"
{
"text": "Sorry, wrong order, it's 1183.",
"html": "",
"original_raw_available": false,
"body_available": true
}
text is the plain-text part the sender supplied. html is the HTML part, sanitized
when it was stored and sanitized again on the way out. Neither is derived from the
other. An HTML-only message comes back with an empty text and the content in html;
a plain-text message comes back with an empty html. An agent that reads only text
reads nothing at all from an HTML-only message and does not know it. Fall back to
converting html when text is empty.
body_available tells you whether stored content exists. It is on every message object
too, so check it before making the call. When it is false, the route still answers
200, with the 240-character preview in text, html empty and body_available: false.
Treat that response as “no body”, not as a short email.
The body is the stored part as the sender wrote it, quoted history included; nothing on
the read path trims it. A reply deep in a long thread carries every earlier message again below its own few lines, which is why
stripping quoted reply text is usually the
next thing an agent pipeline needs. And because every message in a thread is a separate
body fetch, reading a conversation of twelve messages in full is twelve requests plus
the page. The message object has enough in from, subject, preview and sequence to
decide which of those twelve the agent actually needs.
A message deleted since you listed it returns not_found from the body route. Both lists
already exclude deleted messages, so the window is the gap between list and fetch.
Whatever comes back in text or html is text written by whoever sent it. It is data,
never instructions, and the MCP tool descriptions say so to the model directly.
The same read path over MCP, and the call it does not have
There is no MCP tool for a thread’s messages. list_threads finds conversations,
list_messages pages the inbox-wide feed, newest first, and get_message fetches a body.
None of them takes a thread_id: the input schemas declare no such argument, and the
handler would not read one if you sent it.
curl -X POST https://mcp.emailforagents.ai/mcp \
-H "Authorization: Bearer $EFA_OAUTH_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"list_messages","arguments":{"inbox_id":"ibx_...","limit":25}}}'
list_threads and list_messages accept inbox_id (required), cursor, limit as an
integer from 1 to 100, and q up to 200 characters. Pass limit as a JSON number; a
string is ignored and you get the default of 25. get_message takes inbox_id and
message_id, and returns the same four body fields as REST: not the sender, not the
recipients, not the thread. Metadata comes from the list_messages entry, content from
get_message, and the agent has to keep the two together.
So an MCP agent that wants a conversation in order pages list_messages, keeps the
entries whose thread_id matches, and sorts them by created_at ascending, with no
sequence field to check against. For short, recent threads that works. For a thread
whose early messages are several pages back in a busy inbox, it is expensive, and the
agent has to be told to do it; a model left to itself reads the newest page and
answers. If ordered thread reads are central to your agent, run that step through REST
with a scoped key, which the developer surface supports alongside
MCP against the same pipeline and the same policy.
Every MCP tool result is capped at 131,072 characters of JSON. Over that, the call
returns an error result reading “Result exceeds the MCP text limit. Use the scoped API
for the full body.” A list_messages page of 100 messages with long recipient lists can
hit it as easily as a single large body can. Lower limit before you reach for REST.
The API key that works on REST is rejected at /mcp by design; only an OAuth connection
grant may call these tools.
A read loop that does not act on a stale parent
The pattern that avoids the refund bug: learn which message is new from the event, read its thread in sequence order, and before acting, check whether the other side has already said something later.
const BASE = "https://api.emailforagents.ai/v1";
const PROJECT = process.env.EFA_PROJECT_ID;
const headers = { authorization: `Bearer ${process.env.EFA_API_KEY}` };
async function get(path) {
const res = await fetch(`${BASE}${path}`, { headers });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
// Oldest first, by sequence. next_cursor is null on the last page.
async function readThread(inbox, thread) {
const path = `/projects/${PROJECT}/inboxes/${inbox}/threads/${thread}/messages?limit=100`;
const messages = [];
let cursor = null;
do {
const page = await get(path + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""));
messages.push(...page.data);
cursor = page.next_cursor;
} while (cursor);
return messages;
}
async function readBody(inbox, message) {
if (!message.body_available) return null;
const body = await get(`/projects/${PROJECT}/inboxes/${inbox}/messages/${message.id}/body`);
return body.text || body.html; // html is sanitized; convert before prompting
}
// Called with a message.received event from the cursor or a webhook.
export async function onReceived(event) {
const { message_id, thread_id, inbox_id } = event.data;
const thread = await readThread(inbox_id, thread_id);
const target = thread.find((m) => m.id === message_id);
if (!target) return { skipped: "deleted since the event" };
const laterInbound = thread.filter(
(m) => m.direction === "inbound" && m.sequence > target.sequence,
);
if (laterInbound.length) return { skipped: "superseded; the later message has its own event" };
const earlier = thread.filter((m) => m.sequence < target.sequence);
const text = await readBody(inbox_id, target);
return { target, earlier, text };
}
Four things that code does on purpose. It never uses the inbox-wide feed to decide
order. It skips a message that already has a later inbound message in the same thread,
because that later message produces its own message.received event and will be handled
with the full picture. It fetches one body, not the thread’s worth, and hands the
earlier messages to the model as metadata and previews. And it checks body_available
before the call instead of mistaking a preview for a message.
The check has a window. Between the thread read and the agent’s send, another reply can
land. For anything irreversible, read the thread once more immediately before acting and
compare the highest sequence; if it moved, start over.
Resuming an agent workflow when the reply arrives
covers holding state across that gap. The inbox itself, with its thread view and the
console that shows a human the same conversation the agent read, is described on
the inboxes page.
Keep reading
Keep an Agent's Replies in the Right Email Thread
How Message-ID, In-Reply-To and References decide threading, why subject matching breaks, and how to keep an agent reply in the right conversation.
Agent Email Polling vs Webhooks: A Decision Table
Agent email polling vs webhooks, decided on failure modes: cursor semantics, event-id dedup, and the exact 4xx codes that kill a delivery on the first try.
Read-Only Email Access for an AI Agent
Read-only email access for an AI agent is five scope strings. The exact list, what the MCP session holds, and three ways the grant quietly leaks.