Integrations · September 23, 2026
A LangChain Email Tool That Can Receive
A langchain email tool that receives as well as sends: the current ToolRuntime interface, idempotency keys that survive a resume, and which events fire.
Almost every published langchain email tool is one function: take a recipient, a
subject and a body, hand them to SMTP or the Gmail API, return the string
"Email sent to alice@example.com". It works in a demo because the demo ends
there. Nothing in that design tells the agent whether the message left the
building, and nothing at all tells it that a reply came back four hours later.
The agent has a mouth and no ears.
This piece builds both halves against a documented REST surface: the send tool
and the header it cannot omit, the inbound path from event to body, and the
reply that lands in the same thread. It also fixes the return value, because
"Email sent" is a claim the system never made. If you want the same loop
without a framework in the way, read
the agent email API loop. Other client wiring is on
the integrations hub.
What the ranking LangChain email examples actually give you
Three shapes dominate the results, and they fail in different places.
The Gmail toolkit is the official one, installed as
langchain-google-community[gmail], and it exposes five tools:
GmailCreateDraft, GmailSendMessage, GmailSearch, GmailGetMessage and
GmailGetThread (checked on LangChain’s integrations docs, 23 September 2026).
Reading works. Notification does not. There is no push in that toolkit, so an
agent learns about a reply by calling GmailSearch again on a timer, and the
mailbox it reads is a human’s.
The tutorial tool is the SMTP function above. It returns a string, so every distinction the transport made collapses before the model sees it.
The vendor package is the interesting one, and the honest note here is that
it is not send-only. AgentMail’s langchain-agentmail, read on its own README on
23 September 2026, ships fourteen tools, from agentmail_list_inboxes through
agentmail_reply_to_message and agentmail_get_attachment, plus a [webhooks]
extra that installs a FastAPI router with svix-compatible signature verification
that returns 401 on a bad signature. If you were told nobody in this space does
inbound for LangChain, that was wrong when you heard it.
What none of the three settle is the part that bites in production: what a successful send actually proves, what to do when the same tool call runs twice, which events genuinely arrive, and how much of an inbound email you are holding when one does. Those are the sections that follow the setup.
Check LangChain’s tool interface before you copy a 2024 snippet
The interface moved, and most email snippets on the web predate the move. As of LangChain’s current Python docs, read today:
- The decorator import is
from langchain.tools import tool, notlangchain_core.tools. - Agents are built with
from langchain.agents import create_agent. Theinitialize_agentandAgentExecutorscaffolding in older email tutorials is not what the docs show any more. runtimeandconfigare reserved parameter names.configis reserved for theRunnableConfigpassed to tools internally andruntimefor theToolRuntime, so neither can be a tool argument the model fills in.- A parameter annotated
ToolRuntimeis injected by the framework and hidden from the model’s view of the tool schema. The attributes documented today arestate,context,store,stream_writer,execution_info,server_infoandtool_call_id.
That last point is the one to design around. Credentials, project IDs and inbox
IDs belong on the runtime side, where the model cannot see them and therefore
cannot be argued into changing them. The model should see to, subject and
body, and nothing that identifies an account.
Do not take the bullet list above on faith either. Read the tools page in LangChain’s docs before you write the file; this is a surface that has been renamed twice, and a snippet that imports from the wrong module fails at import time if you are lucky and silently binds the wrong object if you are not.
The send tool, and the header that is not optional
The REST base is https://api.emailforagents.ai/v1. Credentials go in
Authorization: Bearer. The send path is
POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages
and it requires an Idempotency-Key header. This is not a recommendation. The
route reads the header before it parses the body, so a request without one fails
with HTTP 400 invalid_request and the message “Idempotency-Key is required for
send operations.” before your JSON is ever validated
(packages/api/src/index.ts:237, status from
packages/contracts/src/errors.ts). A tool that omits the header does not send
fewer duplicates, it sends nothing, and the 400 it gets back says nothing about
the body you were debugging.
import httpx
from langchain.tools import tool, ToolRuntime
API = "https://api.emailforagents.ai/v1"
@tool
def send_email(to: str, subject: str, body: str, runtime: ToolRuntime) -> dict:
"""Send an email from the agent's own inbox. Returns the accepted outcome,
which is not a delivery confirmation."""
ctx = runtime.context
r = httpx.post(
f"{API}/projects/{ctx.project_id}/inboxes/{ctx.inbox_id}/messages",
headers={
"Authorization": f"Bearer {ctx.api_key}",
"Idempotency-Key": f"{ctx.task_id}:{runtime.tool_call_id}",
"Content-Type": "application/json",
},
json={
"to": [{"email": to}],
"subject": subject,
"text": body,
},
timeout=30,
)
r.raise_for_status()
return r.json()
Two shape notes that cost people an afternoon. to is an array of objects with
an email field, not a bare string, and the request schema is strict, so one
unknown top-level key is a 400 rather than an ignored field.
The response is HTTP 202 in two different situations. result is the
discriminator. "queued" carries message_id, thread_id and state.
"approval_required" carries draft_id, approval_id and reason_codes, and
means no message exists at all: a human has to act in the console before
anything is transmitted. Returning a dict rather than a string keeps both
shapes intact, which is the minimum the model needs to avoid announcing a send
that did not happen.
Where the idempotency key should come from in an agent loop
Derive it from the thing you meant to send, not from the attempt. In an agent loop you have three candidates and they protect against different failures.
| Key source | Survives | Does not protect against |
|---|---|---|
uuid4() inside the tool | nothing | every retry, including the HTTP client’s own |
runtime.tool_call_id | HTTP retries, a crashed and resumed run | the model re-planning and emitting a second tool call |
| a business identifier you control | all of the above | nothing, if the identifier is genuinely unique per mail |
runtime.tool_call_id is the cheap correct answer for the harness-level
failures, and it is stable across a resume because it is part of the recorded
tool call rather than something generated at execution time. It is not enough on
its own. If the model decides its first attempt failed and calls send_email
again with the same arguments, that is a new tool call with a new ID, and you
get a second email. Prefix the key with an identifier for the unit of work, as
the example above does with ctx.task_id, so both layers are covered.
The header is bound to the payload, not to the endpoint: the request is
normalised and hashed, so reusing a key with a changed body is an error rather
than a second message. Treat HTTP 409 idempotency_conflict as a bug in how you
derive keys, not as something to retry around. The replay window, the tombstone
period and the states that must never be auto-retried are set out in
the idempotency rules for agent sends.
accepted is not delivered, and message.accepted fires from only one of two paths
A 202 with result: "queued" means the message is stored and accepted for
processing. No provider has been contacted. Later the send intent may move to
accepted, which means one hop succeeded: the upstream provider took the
submission. Neither state says a recipient received anything, and a message to
five recipients has five independent delivery outcomes that are allowed to
disagree.
That much is in the state table. The part that is not, and that decides whether
a LangChain agent resumes or hangs, is which of the two code paths into
accepted emits an event.
The ordinary path is the send worker. It submits to the provider, writes the
resulting state, and emits message.${state} where state is one of
accepted, failed or submission_unknown
(packages/domain/src/services.ts:4459). Because the type is built by
interpolation rather than written out as a literal, grepping the source for the
string "message.accepted" finds only the declaration in
packages/contracts/src/events.ts and misses the emit entirely. It does fire.
The second path is reconciliation. When a worker dies mid-submission the next
lease finds the intent in submitting, moves it to submission_unknown with
last_error_code='interrupted_submission', and returns without emitting
anything at all (services.ts:4359). Nothing moves it on afterwards. The
callback handler has a branch that would flip submitting or
submission_unknown to accepted (services.ts:4561), but it finds the send
by the provider’s email ID, which is only stored when the provider’s reply was
classified as accepted. An unknown send never recorded one, so a later callback
matches nothing and the branch never runs for it
(why it stays unknown).
So the rule to build against: message.accepted is emitted only when the
provider answered the submission in-band, and an unknown send never becomes
accepted on its own. That unknown send is the case where an agent most wants
to hear “it went out after all”, and no event will ever say so. An agent that
blocks on message.accepted therefore hangs on exactly the ambiguous sends. Poll the
message state instead, or treat message.accepted as an optimisation and the
read as the source of truth.
submission_unknown deserves a branch of its own. The system deliberately stops
rather than retrying, because the submission may already have been taken. Your
tool must not round that down to “failed”, because that is how a human presses
resend on a message that already went out. Surface the state and the
last_error_code verbatim and let the run halt.
What each message status actually proves is
the diagnosis order.
Which events exist, and the three ways message.delivery_updated goes missing
packages/contracts/src/events.ts declares eighteen event types. Eleven have an
emit site in this build:
inbox.created domain.updated message.received message.queued
message.accepted message.submission_unknown message.failed
message.delivery_updated approval.requested approval.rejected webhook.test
Seven are declared and never fire: inbox.updated, inbox.deleted,
message.canceled, message.quarantined, approval.approved,
approval.invalidated and project.sending_paused. A webhook subscription
accepts all seven and they are simply never delivered, which is a miserable bug
to chase, because the subscription succeeds and the handler type-checks. In
particular, an agent that blocks until approval.approved arrives blocks
permanently; watch for message.queued on the resulting message instead.
message.delivery_updated is the one that carries per-recipient outcomes, and
it is conditional in three ways the catalogue does not show.
First, the provider’s own vocabulary is not yours. Upstream speaks email.sent,
email.delivered, email.bounced, email.complained, email.suppressed,
email.delivery_delayed and email.failed. Those strings never reach your
project’s event stream. Six of them map to a status of delivered, bounced,
complained, suppressed, delayed or failed and are re-emitted as a single
message.delivery_updated carrying message_id, recipient and status
(services.ts:4540). Type a LangChain tool against the email.* names and you
are typing against an API you are not talking to.
Second, email.sent is the exception. The handler counts it as proof of
acceptance, but it has no entry in the status map.
A send whose only callback is email.sent therefore produces no event at
all: not message.accepted, because the callback path does not emit it, and
not message.delivery_updated, because there is no status to report.
Third, the handler resolves a recipient row only when the callback names exactly one address. A payload listing two or more recipients matches nothing, so the status is dropped and no event is emitted for any of them. The comment in source is explicit that an aggregate payload cannot establish which address bounced and must never suppress all recipients, which is the right call and also means your handler cannot rely on hearing about every recipient.
One more ordering trap. The write to message_recipients is guarded by a rank
so a late delayed cannot overwrite a stored delivered. The emit is not
inside that guard. An out-of-order callback therefore emits a
message.delivery_updated whose status is behind what the database now holds.
Treat the status on the event as a hint and the message read as authoritative,
or a LangChain node that updates state from the event body will walk a thread
backwards from delivered to delayed. Whether you consume these by webhook or by
cursor, the events and webhooks reference is the
list to check before writing a handler.
While reading the catalogue, note that the audit trail is a different namespace.
inbox.policy.updated is an audit action recorded in
packages/domain/src/customer-controls.ts, not an event type, so it never
appears on a webhook however carefully you subscribe.
The receive half: an event tells you a message exists, not what it says
This is where the send-only examples end and the real work starts.
An inbound email produces message.received, and the payload is exactly five
fields:
{
"message_id": "msg_...",
"thread_id": "thd_...",
"inbox_id": "ibx_...",
"environment": "live",
"simulated": false
}
No sender. No subject. No body. That is deliberate, and it means the event is a
trigger, not a delivery of content. Reading one inbound email is three calls:
the event, then the message metadata from
/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages, then
the body from
/projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body.
Skipping the third call is the common shortcut, and it is wrong. The message
listing carries a preview, which is a truncation, not the email. Build a
summariser on preview and it will confidently describe the first line of a
thread as the whole message.
The body endpoint has one response field that decides how your tool should behave:
{ "text": "...", "html": "...", "original_raw_available": false, "body_available": true }
When a message has no stored content manifest the endpoint still returns 200,
with text set to the preview, html set to "", and body_available set
to false (packages/domain/src/services.ts:3508). A tool that returns
payload["text"] and nothing else hands the model a truncated preview that is
indistinguishable from a short email. Return the flag and say what it means:
@tool
def read_email(message_id: str, runtime: ToolRuntime) -> dict:
"""Fetch the full text of one received email. If body_available is false,
only a truncated preview exists and you must not treat it as the message."""
ctx = runtime.context
r = httpx.get(
f"{API}/projects/{ctx.project_id}/inboxes/{ctx.inbox_id}"
f"/messages/{message_id}/body",
headers={"Authorization": f"Bearer {ctx.api_key}"},
timeout=30,
)
r.raise_for_status()
d = r.json()
return {
"text": d["text"],
"complete": d["body_available"],
"note": None if d["body_available"] else "truncated preview only",
}
Two operational points. The credential needs messages:read and threads:read
for this path, and events:read for the cursor. And whatever arrives here is
untrusted input written by a stranger, so it is data for the model to read, never
instruction for it to follow.
Replying into the same thread
Pass the inbound message’s ID as reply_to_message_id on an otherwise ordinary
send. The parent’s thread is reused and the reply takes the next sequence number
in it. Recipients and subject are still explicit; the field threads the message,
it does not address it.
{
"to": [{"email": "ap@example.com"}],
"subject": "Re: Receipt for invoice 4471",
"reply_to_message_id": "msg_7f21c...",
"text": "Corrected receipt attached.",
}
The parent must live in the same inbox, and the credential needs messages:read
on top of its send scope, because replying reads the thread as well as writing to
it (packages/domain/src/services.ts:1541). reply_to_message_id is part of the
canonical payload that the idempotency key is hashed over, so two replies to
different parents cannot collide on one key even with identical bodies.
What this field does not control is what the recipient’s client shows. Threading
is resolved into In-Reply-To and References at submission time, and Gmail or
Outlook then group by their own rules.
How an agent’s replies stay in the right thread
covers the header mechanics and the ways grouping breaks anyway.
Rehearsing both directions before anything is routable
You can exercise the whole loop, including inbound, without a message crossing a
transport. A test project’s inboxes get a non-routable .mail.invalid address
and a simulated transport, and
POST /v1/projects/{project_id}/test/inbound
injects a synthetic message into one of them. The body takes inbox_id, from,
to, subject and one of text or html. It requires both test:simulate and
messages:read on the credential, and it returns HTTP 403 test_mode_required
on a live project (packages/domain/src/services.ts:2123, status mapped in
packages/contracts/src/errors.ts). The resulting
message.received event is identical in shape to a live one except that
environment is "test" and simulated is true, so your LangChain handler
takes the same branch it will take in production.
Note what this is and is not. A test inbox cannot reach an internet recipient and nothing is transmitted into it, so what you are rehearsing is your own handler, not deliverability. That is a narrower thing than a routable sandbox mailbox receiving a real message from outside, and the two are not interchangeable. The sandbox checklist is in testing an agent’s email without sending real mail.
Environment cannot be crossed by configuration. An efa_test_ key resolves only
to test projects and test inboxes, and no flag points it at live, so the failure
mode of a bad deploy is a rejected request rather than an email to a customer.
What this does not give you
Four limits worth knowing before you wire this into a LangChain agent.
Live mail runs on agents.emailforagents.ai. Custom domains require a paid plan
and those plans are not purchasable yet, so the agent’s address will carry that
subdomain.
The free allowance is one live inbox and 100 email units a month per verified owner identity, where a unit is one message received or one recipient sent to. A reply-all to four people costs four. When the allowance runs out, sending stops and received mail stays readable.
There is no push into LangChain. You subscribe a webhook or poll the project event cursor, and either way something you own has to receive the callback and start or resume the agent. The framework has no inbound transport of its own.
And acceptance is never proof of receipt. The best the return value can honestly say is which hop succeeded, which is exactly why it should be a structured object rather than the word “sent”.
Keep reading
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.
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown: what each email status actually proves, what it does not, and the correct next action for every one of them.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.