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.

CallREST routeMCP toolScope checked
Find conversations.../inboxes/{inbox_id}/threadslist_threadsthreads:read
Read one conversation, in order.../threads/{thread_id}/messagesnonemessages:read
Flat feed of the inbox.../inboxes/{inbox_id}/messageslist_messagesmessages:read
One message’s content.../messages/{message_id}/bodyget_messagemessages: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.

RouteSort keyDirectionDefault limit
.../threadslast_message_at, then IDNewest first25
.../inboxes/{inbox_id}/messagescreated_at, then IDNewest first25
.../threads/{thread_id}/messagessequence, then IDOldest first50

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

All guides · Documentation · Inboxes