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.
| Call | Order | Default | Carries sequence | Carries body_available |
|---|---|---|---|---|
…/inboxes/{id}/threads | last_message_at desc | 25 | no | no |
…/inboxes/{id}/threads/{tid}/messages | thread_sequence asc | 50 | yes | yes |
…/inboxes/{id}/messages | created_at desc | 25 | no | yes |
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.
| Identifier | Where it comes from | What it is for |
|---|---|---|
thd_ thread ID | Thread list, or any message row | The memory key. Path parameter for rehydration. |
msg_ message ID | A message row | reply_to_message_id on a send. Path parameter for a body. |
sequence (integer, thread-scoped) | Thread message rows only | A 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
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 API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.