Adding email to a LangChain or LangGraph agent

LangChain agents are usually strong at the deciding and weak at the being reachable. Adding email is not hard, but where you put it in the graph decides whether the agent can hold a conversation or merely send notifications into the void.

4 min read

A graph with an email node reading a thread and replying
The tools are the easy part. Where they sit in the graph is the design.

Two ways in

MCP, if your version has the adapter. LangChain's MCP support turns a server's tools into LangChain tools, so the entire surface arrives at once and stays current when it changes. Point it at the endpoint with a key scoped to one agent identity:

JSON
{
  "url": "https://connect.mcpmailer.com/mcp",
  "headers": { "Authorization": "Bearer mmk_live_..." }
}

Plain tools, otherwise. Define four functions that call the REST API with the same key. Four is usually right, for the reasons in giving an OpenAI-based agent an email address: every extra tool is another thing the model can misuse.

Python
from langchain_core.tools import tool
from mcpmailer import Mcpmailer

mail = Mcpmailer()  # reads MCPMAILER_API_KEY

@tool
def get_thread(thread_id: str) -> dict:
    """The whole conversation, oldest first, quoted history removed. Call before writing."""
    return mail.get_thread(thread_id)

@tool
def lookup_contact(email: str) -> dict:
    """What the workspace already knows about this address. Call before writing."""
    return mail.lookup_contact(email)

@tool
def reply_all(message_id: str, body: str) -> dict:
    """Reply in thread, keeping the audience. Use this rather than composing a new message."""
    return mail.reply_all(message_id, body)

@tool
def escalate(message_id: str, why: str) -> dict:
    """Hand the thread to a human. Use when a rule fires or a claim cannot be sourced."""
    return mail.mark_unread(message_id)

The docstrings are load-bearing. "Call before writing" in the first two is what actually produces the read-thread-then-contact discipline that separates a good agent from one that answers the last message in isolation.

Where email belongs in a graph

The instinct is one node that does email. The better shape splits it, because the three parts have different failure modes.

NodeDoesFails by
IngestFetch thread and contact, normalise into stateAnswering the message instead of the conversation
DecideModel call, no tools boundUngrounded answers, injection
ActReply, escalate, or waitDouble sends, ignored refusals

Keeping the decide node tool-free is the important one, and it is the same separation argued in prompt injection by email: untrusted text and privileged tools should not share a call. The model reads a message and returns a structured decision; your graph, not the model, checks it against what this agent is allowed to do and then acts.

Ingest, decide, act as three nodes rather than one email node
Three nodes. The middle one holds no tools, which is the point.

State that survives the wait

Email conversations outlast a run, so a graph that only exists inside one invocation cannot hold one. Two options, and they compose.

Inside an active conversation, wait_for_reply blocks server-side and returns when the reply lands or the timeout expires, so a single graph run can span a multi-turn exchange without a scheduler.

For anything longer, checkpoint the graph state keyed on the thread id, end the run, and resume from a message.received webhook. The rebuild is cheap: fetch the thread, look up the contact, read your own systems. Nothing else needs to persist, and copying the thread into your own store is the mistake described in long-running email conversations.

Python
reply = mail.wait_for_reply(thread_id, timeout_seconds=900, poll_seconds=15)
if reply is None:
    follow_up_once(thread_id)   # silence is information

Return refusals into the graph

The most common integration bug is catching a rejected send and returning a generic error, which the model then retries. Pass the reason through as data the graph can branch on.

Python
res = mail.send(to=[addr], subject=subj, body=body)
if res["status"] == "rejected":
    return {"blocked": res["reason"], "retry_after": res.get("retryAfter")}

daily_send_quota_exhausted and monthly_send_quota_exhausted carry a reset, recipient_suppressed means that address bounced or complained before, and monthly_spend_cap_reached means only the workspace owner can lift it. A graph that routes on these waits or escalates; a graph that sees Exception loops.

Retrieval, and the thing people forget

If your agent answers from documentation, the retrieval step belongs before the decide node and its result belongs in state, not in the prompt as free text. That matters for a specific reason beyond tidiness: the decide node should be able to say which source it used, so your act node can enforce the rule that an unsourced claim escalates instead of sending. That single check prevents the expensive failure, which is a fluent wrong answer, per the system prompt for an email agent.

Before it emails a customer

  1. One identity and key per agent, never shared, per keys and scope.
  2. Whitelist mode on the identity while you build trust.
  3. Per-thread serialisation, because bursts arrive, per what happens when forty messages arrive at once.
  4. Idempotency on the message id, since webhook deliveries retry.
  5. A replayable set of golden threads before any prompt change ships.

Questions

Can a LangChain agent send and receive email?
Yes. Use the MCP adapter where your version supports it to pick up the whole tool surface, or define a few plain tools that call the REST API with a key scoped to one agent identity.
How many email tools should I define?
About four: get the thread, look up the contact, reply in thread, and escalate. Docstrings that say "call before writing" do real work in enforcing the right order.
Where should email go in a LangGraph graph?
Split into ingest, decide, and act nodes. The decide node should hold no tools, so untrusted message text never shares a call with a privileged action.
How does the graph survive a reply that arrives tomorrow?
Either wait_for_reply inside the run, or checkpoint on the thread id, end the run, and resume from a message.received webhook by refetching the thread and contact.
What should a tool return when a send is refused?
The reason, as data. Quota reasons carry a reset time, suppression means stop, and a spend cap needs the owner. A generic exception makes the model retry.
Do I need the Python SDK?
No, plain HTTP works, but the SDK is standard library only and saves the boilerplate. See sending and receiving email from a Python agent.

Give your agent an address it can answer from.

Create an inbox