Integrations · September 23, 2026

Letta Agent Email Address: Store Thread IDs, Not Text

Give a Letta agent email address durable context: keep thread IDs in a memory block, rehydrate from the threads endpoint, and see what body_available hides.

Every Letta email tutorial ends the same way. The agent reads the inbox, writes a summary of what it read into a memory block, and the block fills up. Blocks have a character limit, value replaces rather than appends, and the agent is the one deciding what to evict. Within a week the agent’s recollection of a customer thread is a paraphrase of a paraphrase, while the actual thread sits untouched in the mailbox.

A Letta agent email address is worth wiring up only if you invert what gets stored. Mail is already durable, already ordered, and already held by the counterparty, so the memory block should hold identifiers. Concretely: create an inbox, register one REST tool with create_from_function, and keep thd_ identifiers plus a sequence watermark in a block. This piece covers the three listing endpoints and how they disagree, the two separate ways body_available will mislead you, and the thing test injection cannot prove no matter how many messages you push through it. If you are choosing a transport first, the integrations overview lists what an agent can connect through.

What a Letta agent email address should store in memory

The rule is one line: a memory block holds thd_ identifiers and a sentence of provenance, never message bodies.

Letta’s memory blocks are sections of the context window that persist across interactions, with four fields that matter here, label, description, value and limit, plus a read_only flag. The docs are explicit that setting value completely replaces the block content, and that with concurrent writers “the last write wins and overwrites all earlier changes”. Every write is a full rewrite and every rewrite is a chance to lose something. A block whose value is a 5,000-character digest of eleven email threads is a lossy cache of data you already have, and two conversations editing it at once make it a lossy cache with a race.

A block that instead holds this is smaller and lossless:

label: open_mail
description: Email threads this agent is responsible for. Rehydrate
  bodies from the mail API by thread_id before answering. Never
  summarise a thread here.
value:
  thd_9c41... | ap@example.com | invoice 4471 dispute | last seen seq 6
  thd_2f08... | legal@acme.test | MSA redlines | last seen seq 2

Roughly ninety characters per thread, so the agent can carry forty of them without the block becoming the thing that needs summarising. Letta’s archival memory is the other half: archival_memory_insert and archival_memory_search give the agent a semantically searchable store for the conclusions it drew, and conclusions are what should be written in prose. The evidence stays in the mailbox.

There is a detail in Letta’s current model that older write-ups get wrong. A lot of material still in circulation says Letta agents have no threads or sessions, only one perpetual message history. Letta’s docs now define a conversation as one message thread with an agent, note that one agent can have many conversations running in parallel, and state that every conversation uses the same agent identity and long-term memory. Verified on docs.letta.com on 23 September 2026. That matters for mail: two email threads handled in two Letta conversations both see the same open_mail block and can both write to it, so the block needs a stable key and an append discipline. thd_ is that key.

Three listing endpoints that disagree with each other

There are three ways to list mail and they return different shapes, different orders and different fields. Picking the wrong one is the most common way this integration breaks quietly.

CallOrderDefaultCarries sequenceCarries body_available
…/inboxes/{id}/threadslast_message_at desc25nono
…/inboxes/{id}/threads/{tid}/messagesthread_sequence asc50yesyes
…/inboxes/{id}/messagescreated_at desc25noyes

The thread list is the index. Each row is exactly id (a thd_ identifier), inbox_id, subject, preview, message_count and last_message_at, and that is the whole row. The preview is taken from the message with the highest thread_sequence, so it describes the most recent turn rather than the subject of the conversation, which is right for a “what changed” sweep and wrong for deciding what a thread is about. Note the third column: the thread index tells you nothing about whether any body is retrievable. An agent that plans work off the thread list alone is planning against previews.

The thread’s message list is the transcript, ascending by thread_sequence, and its default page size is 50 rather than 25. Its rows carry sequence, direction, from, state, created_at, delivery_status, body_available, and recipient and attachment metadata.

The flat inbox list is the third one and it looks interchangeable. It is not. Its rows omit both sequence and delivery_status, verified in listMessagesPage in packages/domain/src/services.ts. Use it to notice new mail, use the thread endpoint to read a conversation. An agent that reconstructs a thread by filtering the flat list client-side gets the turns in reverse and with no sequence numbers, which is exactly the bug that produces a reply to the wrong turn.

All three accept limit and q. limit is 1 to 100; anything else, and any q over 200 characters or containing a byte below 0x20, is HTTP 400 invalid_request with the message limit must be 1–100 and q at most 200 printable characters. q is a full-text match over stored subject, preview, sender address and sender display name. It does not search message bodies or attachment contents. It finds a conversation; it is not retrieval over your corpus. Verified in packages/domain/src/mail-pages.ts, where the query builds its tsvector from exactly those four columns.

Pagination has a property worth knowing before you write a loop. next_cursor is an opaque base64url payload with an HMAC suffix, and the payload is bound to the workspace, project, inbox, listing kind and the exact q string. For a thread’s message list the listing kind includes the thread ID, so a cursor cannot be replayed against a different thread either. Change the search term mid-scan and you get invalid_cursor with the message Cursor is invalid, expired, or belongs to another filter. Cursors also carry a 24-hour expiry, so an agent that persists a cursor in a memory block and resumes on Monday restarts the scan. Persist the thd_ identifier instead. It does not expire.

Thread ID versus message ID: which one memory holds

Hold the thread ID in memory. Pass the message ID when you reply.

They are different objects with different lifetimes. A thd_ identifier names the conversation and is stable for as long as the conversation exists, while message_count and last_message_at change underneath it. A msg_ identifier names one turn and never changes. An agent that stores msg_ identifiers accumulates one entry per turn and still cannot ask “what happened here since I last looked” without a scan.

IdentifierWhere it comes fromWhat it is for
thd_ thread IDThread list, or any message rowThe memory key. Path parameter for rehydration.
msg_ message IDA message rowreply_to_message_id on a send. Path parameter for a body.
sequence (integer, thread-scoped)Thread message rows onlyA watermark. Store the highest one you processed.

That third row is the one people skip. Store last seen seq next to the thread ID and the next pass is a single call: fetch the thread’s messages, discard everything at or below the watermark, act on the remainder. No diffing, no re-reading, and no double-reply when Letta runs two conversations against the same mailbox.

Replying is a separate concern from rehydrating, and the field that does the work is reply_to_message_id on the send call, not the thread ID. It is the only thing that attaches an outbound message to an existing thread. Omit it and a fresh thd_ is minted even when the subject matches exactly, because the send path branches on that one field and nothing else, in packages/domain/src/services.ts. When it is present, the sequence is allocated by an atomic UPDATE threads SET last_message_sequence = last_message_sequence + 1 … RETURNING, so two concurrent replies cannot collide on a number. The parent must live in the same inbox, and the credential needs messages:read in addition to its send scope, enforced at services.ts:1541, because replying reads the thread as well as writing to it. What happens to In-Reply-To and References afterwards, and the ways a correct thread on your side still renders as a broken thread in the recipient’s client, are covered in keeping an agent’s replies in the right thread.

Two ways body_available will mislead a Letta agent

The body is a separate call:

GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body

It returns text, html, original_raw_available and body_available. Most write-ups treat that last field as “the body is here”. It is not. It is a boolean over whether a stored content manifest exists, and it is wrong for an agent’s purposes in both directions.

False does not mean empty. When body_available is false the endpoint still returns HTTP 200 and a populated text field, and that text is the message preview, not the body. There is no error, no empty string, nothing in the shape of the response to distinguish a 240-character preview of a long email from a genuinely short one. From packages/domain/src/services.ts, the body call short-circuits when the message row has no manifest and returns { "text": <preview>, "html": "", "body_available": false }. The preview itself is built by collapsing every whitespace run to a single space and slicing to 240 characters, in packages/domain/src/content.ts. Note the collapse: paragraph breaks are gone, so the truncated text does not even read as truncated.

True does not mean text is populated. This is the trap that survives the obvious fix. persistMessageContent always writes both a text object and an HTML object, even when one of them is zero bytes, so a message that arrived as HTML only still gets a manifest and still reports body_available: true. The body call then reads a zero-byte text object and hands back text: "" with the entire message sitting in html. An agent that branches on body_available and reads only text, which is what every example in this space does, renders that email as blank and will confidently report that the customer sent an empty message.

Branch on both, and make the failure visible to the model rather than hiding it:

def read_email_thread(thread_id: str) -> str:
    """Read the full transcript of one email thread.

    Args:
        thread_id (str): The thd_ identifier from the open_mail memory block.

    Returns:
        str: One block per message, oldest first.
    """
    import os, json, re, urllib.request

    base = "https://api.emailforagents.ai/v1/projects/%s/inboxes/%s" % (
        os.environ["EFA_PROJECT_ID"], os.environ["EFA_INBOX_ID"])
    auth = {"Authorization": "Bearer " + os.environ["EFA_API_KEY"]}

    def get(url):
        req = urllib.request.Request(url, headers=auth)
        with urllib.request.urlopen(req) as res:
            return json.load(res)

    out = []
    page = get("%s/threads/%s/messages?limit=50" % (base, thread_id))
    for m in page["data"]:
        if not m["body_available"]:
            body = m["preview"] + "\n[preview only: full body unavailable]"
        else:
            doc = get("%s/messages/%s/body" % (base, m["id"]))
            # body_available is true whenever a manifest exists, which includes
            # HTML-only mail whose stored text part is zero bytes.
            body = doc["text"].strip() or re.sub(r"<[^>]+>", " ", doc["html"])
            body = body.strip() or "[empty body]"
        out.append("seq %s | %s | %s\n%s" % (
            m["sequence"], m["direction"], m["from"]["email"], body))
    if page["has_more"]:
        out.append("[truncated: more messages in this thread]")
    return "\n\n".join(out)

The explicit markers are the point. A Letta agent handed silently truncated text writes a confident summary into archival memory, and that summary outlives the mistake. An agent handed [preview only: full body unavailable] says so instead.

Registering it as a Letta tool

Letta’s documented path for a server-side Python tool, checked on docs.letta.com on 23 September 2026:

from letta_client import Letta

client = Letta(api_key=os.getenv("LETTA_API_KEY"))
tool = client.tools.create_from_function(func=read_email_thread)

pip install letta-client. The Google-style docstring is not decoration. Letta constructs the OpenAI tool schema from the function, parsing it out of a correctly formatted docstring unless you pass a Pydantic schema explicitly, so the Args: block is literally what the model sees. Say “the thd_ identifier from the open_mail memory block” there, not “the thread”, because that sentence is the only thing connecting the tool to the block.

Credentials go in as agent-scoped tool environment variables rather than in the function body, and the tool reads them with os.environ inside Letta’s sandbox. Letta also injects LETTA_AGENT_ID, LETTA_PROJECT_ID and LETTA_API_KEY into that environment automatically, so pick names that will not collide.

Scope the key to what the tool does. Reading a thread needs threads:read and messages:read and nothing else, and those are two separate scope checks on two separate calls, so a key with only messages:read can read a thread it already knows the ID of but cannot list threads to discover one. The scope list runs to 32 entries, and a credential cannot exceed the role of the human who created it: requesting a scope your own role lacks fails at creation with permission_denied rather than later at use time. Authority is re-read on every request, so removing that human’s project assignment narrows every key they minted on the next call, with no cached-token window. The developer surface documents the full set.

Why REST rather than remote MCP, for Letta today

Letta supports MCP servers, and its docs list streamable HTTP as recommended, SSE as legacy and for compatibility only, and stdio for local development. Registration is client.mcp_servers.create(...), and the documented authentication is header-based: auth_header, auth_token and custom_headers, with templated variables available for per-agent credentials.

That is the mismatch. An API key is rejected at https://mcp.emailforagents.ai/mcp by design. Only an OAuth connection grant may call the tools. Letta’s MCP documentation covers bearer tokens and custom headers and does not document an OAuth authorization flow for remote MCP servers, so the straightforward auth_token configuration will not authenticate here. Verified on docs.letta.com on 23 September 2026; if Letta ships an OAuth flow for remote servers, re-check this before ruling MCP out.

Where MCP is reachable it is a good fit, because the tool surface is itself the permission boundary: tools/list is filtered by grant and tools/call re-checks it, so a read-and-draft grant produces a session in which send_message is not present at all. There are six tools, list_inboxes, list_threads, list_messages, get_message, create_draft and send_message, and create_draft cannot transmit mail. For Letta specifically, write the REST tool today.

Sending, and the gate that makes it safe to leave running

Give the agent an inbox on the agent inbox product, then decide whether it may transmit. A credential holding drafts:submit but not messages:send composes fully formed messages that always land in the approval queue.

Read the body, not the status code. The send route returns HTTP 202 for every send, approved or not, so the discriminator is the result field. A held send carries "result": "approval_required" with a draft_id, an approval_id and reason_codes.

reason_codes has a shape that catches people. The policy rule types are always, new_recipient_domain and attachments, and draft_only_principal is a fallback, not an addition: the response uses the matched policy reasons if there are any, and substitutes ["draft_only_principal"] only when the list is empty (packages/domain/src/services.ts:1687). So a draft-only credential mailing a new domain gets ["new_recipient_domain"] and no mention of the credential at all. An agent that checks for draft_only_principal to decide “this was only my permissions, a human will wave it through” will misread exactly the sends that most needed a human.

The gate emits events, and here the honest statement matters more than the impressive one. This build emits approval.requested and approval.rejected. approval.approved and approval.invalidated are declared in the event contract and are fired by no code path, so a Letta agent waiting on approval.approved to update its memory block waits forever. Poll the message state or the thread instead. The approval workflow for agent email sets out the queue mechanics.

Sends also require an Idempotency-Key header. Without one the request fails with HTTP 400 invalid_request before anything else is evaluated. Derive the key from intent, such as thd_9c41-seq7-reply-v1, never from the retry attempt, because a fresh key per attempt is precisely the duplicate the header exists to prevent.

What a test inbox can and cannot prove

A test inbox uses a non-routable .mail.invalid address and a simulated transport, and it cannot reach an internet recipient. Inbound arrives by injection. The request schema is strict, to is required with at least one recipient, and the call needs both test:simulate and messages:read:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/test/inbound" \
  -H "Authorization: Bearer $EFA_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inbox_id":"ibx_...",
       "from":{"email":"ap@example.test"},
       "to":[{"email":"agent@acme.mail.invalid"}],
       "subject":"Invoice 4471 dispute",
       "text":"The line item on page 2 is wrong."}'

It returns HTTP 201 with the new msg_ and thd_. If either the project or the inbox is live you get test_mode_required rather than a silent live send.

Now the part that is not written down anywhere else, and that the rest of this article depends on. Test injection never threads. Every call to /test/inbound mints a fresh thd_ with message_count 1 and thread_sequence 1. There is no header field in the request schema to thread against, the schema is strict so adding one is a 400, and simulateInbound in packages/domain/src/services.ts assigns crypto.randomUUID() to the thread unconditionally. Live inbound behaves completely differently: it resolves a parent by normalising In-Reply-To and References against stored message IDs, and also against the agent’s own previously sent Message-ID recorded as source='outbound_confirmed', then increments the existing thread’s sequence.

So the loop you can prove in a test inbox is narrower than it looks. You can inject, rehydrate, and reply with reply_to_message_id, which does attach to the thread and does produce sequence 2. What you cannot produce is a second inbound turn on the same thread, which means the inbound half of the watermark is the one part of this design that test mode will not exercise. Plan for that: assert the watermark logic in a unit test against a fixture, not against an injected inbox, and treat the first live reply from a counterparty as the real integration test.

Three assertions are still worth writing before this goes near live mail. That the agent’s open_mail block contains a thread ID and a sequence watermark and no prose. That an outbound reply carrying reply_to_message_id lands on the same thd_ at sequence 2 rather than minting a new thread. That a message with body_available: true and an empty text produces content from html rather than a blank turn.

This category is crowded and the distinction here is narrower than the marketing in it usually admits. Other inbound testing products exist and several are good. Mailtrap ships Agent Inbox with a routable hosted address, and Mailosaur documents originating a message itself. The difference is not that inbound testing is rare. It is that here the message crosses no transport and lands in the agent’s own mailbox, rather than being genuinely transmitted into a stand-in mailbox. An efa_test_ key resolves only to test projects and test inboxes and no flag lets it reach live, so the same tool code is promoted unchanged. The sandbox checklist covers what else differs when you swap the credential, and the full REST loop covers the send states the watermark approach assumes.


Keep reading

All guides · Documentation · Integrations