LangChain tools
langchain-replylayer gives a LangChain agent six email tools — send, reply, list,
read, long-poll, and quota — packaged as a BaseToolkit. Each tool is a thin
wrapper over the official Python SDK, so every send still passes the
same allowlist, quota, human-approval, and content-scanning gates as any other
ReplyLayer send. Scanning reduces risk; a clean verdict is not a trust verdict —
the tools carry that discipline into the schema your model sees.
The tools never raise for a governed outcome. Instead they return a
JSON-serializable dict with a status field your agent branches on. Only two
things raise: an authentication failure (a bad key) and an unexpected server error
(an infrastructure fault) — the faults an agent cannot act on.
Install
pip install langchain-replylayerRequires Python 3.10 or newer; it pulls in the replylayer SDK and
langchain-core. To run the tools inside a real agent loop, install the
examples extra, which adds langchain and a chat-model provider:
pip install "langchain-replylayer[examples]"Build the toolkit
You need a ReplyLayer API key. For an agent, use a mailbox-bound agent key, not an admin key — see Authentication for how keys and roles work and where to create one.
import os
from langchain_replylayer import ReplyLayerToolkit
toolkit = ReplyLayerToolkit(
api_key=os.environ["REPLYLAYER_API_KEY"],
default_mailbox_id="[email protected]",
)
tools = toolkit.get_tools()api_key— held as a secret, so it is redacted fromrepr(),model_dump(), and every generated tool schema (nothing your tracing records ever contains the key value). If you omit it, the toolkit falls back to theREPLYLAYER_API_KEYenvironment variable.default_mailbox_id— the mailbox thesend_email,list_messages, andwait_for_messagetools use when a call doesn't name one. Set it once here and your agent never has to supply it.base_url— defaults to the production API. Point it at another environment to test against a sandbox.
Wire it into an agent
Get the tools with get_tools() and hand them to your agent. With the examples
extra installed (and OPENAI_API_KEY set for the provider below):
from langchain.agents import create_agent
agent = create_agent("openai:gpt-4o-mini", tools)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Email [email protected] that her order shipped."}]}
)You can also invoke a tool directly — useful for exercising the status contract without a model in the loop:
by_name = {tool.name: tool for tool in tools}
outcome = by_name["send_email"].invoke(
{
"to": "[email protected]",
"subject": "Your order shipped",
"body": "Tracking number is 1Z999AA10123456784.",
}
)The six tools
| Tool | What it does | Key result fields |
|---|---|---|
send_email | Send a new outbound email (to, subject, body, optional html, from_mailbox, idempotency_key). | status, message_id |
reply_to_email | Reply to an inbound message, continuing its thread (message_id, body). | status, message_id |
list_messages | List recent messages as compact rows (paginated with cursor / before). | status, messages[], has_more, cursor |
read_message | Read one message in full, with its safety context. | status, body, agent_safety_context, … |
wait_for_message | Long-poll a mailbox for the next message (up to 30s). | status, message |
check_send_quota | Preflight the remaining daily send budget (no arguments). | status, quota |
send_email and reply_to_email accept an optional idempotency_key so a retried
identical send produces at most one email and one charge; the adapter adds no retry
logic of its own. Use check_send_quota before a burst of sends —
quota.sends_remaining and quota.reset_at are top-level, and the effective daily
limit is at quota.today.limit — rather than discovering the ceiling by hitting a
rate_limited send.
Send and reply outcomes
send_email and reply_to_email return one of these status values. Branch on
status, then read code for the machine-legible reason.
status | Meaning | What the agent should do |
|---|---|---|
sent | Accepted for delivery. message_id is the handle. | Continue. "Accepted" is not "safe" — the scan reduces risk, it doesn't certify content. |
rejected_by_policy | A pre-send gate refused the recipient before any bytes left (not on the allowlist, agent-contained, suppressed, a failed recipient verification, a sandbox limit, or a billing gate). Read code. | A human or admin must lift it — an agent cannot. See the branching example below. |
rejected | A content-scan policy blocked the message. | Edit the content or escalate to a human. Never resend the same content unchanged. |
held_for_human_review | Queued for a person to approve before it can send. message_id tracks it. | Wait for the human decision. Do not resend — that creates a duplicate hold. |
retry_later | A transient infrastructure hold. | Retry after retry_after. |
rate_limited | A send limit was hit. Read variant. | Back off; preflight future bursts with check_send_quota. |
error | Another client-side problem. Read code / details. | Fix the request; do not blindly retry. |
The rejected, held_for_human_review, and retry_later results also carry
agent_instructions — a structured list of next steps derived from the scan
findings (never parsed from prose). Surface them to your model or your operator.
For the full catalog of send-gate codes and what lifts each, see Why a send was refused and the error reference.
Branching on the outcome
The two branches worth handling explicitly are rejected_by_policy (a person has
to change configuration) and held_for_human_review (a person has to approve):
outcome = by_name["send_email"].invoke(
{"to": "[email protected]", "subject": "Your order shipped", "body": "It's on the way."}
)
status = outcome["status"]
if status == "sent":
message_id = outcome["message_id"]
elif status == "rejected_by_policy":
# A pre-send gate refused the recipient. Read outcome["code"] — e.g.
# RECIPIENT_NOT_ON_ALLOWLIST means a human admin must add the recipient to the
# mailbox allowlist. A contained agent cannot un-contain itself.
code = outcome["code"]
elif status == "held_for_human_review":
# Queued for a person to approve. outcome["message_id"] tracks it.
# Do not resend — that would create a duplicate hold.
pending_id = outcome["message_id"]
elif status == "rejected":
# A content-scan policy blocked it. Edit the content or escalate; never resend
# the same content unchanged.
for hint in outcome.get("agent_instructions", []):
print(hint)
else: # "retry_later", "rate_limited", or "error"
code = outcome.get("code")A rejected_by_policy on the recipient allowlist is resolved by a human admin
adding the address (or by replying within an existing thread) — see
Recipient allowlist for the containment model
and how to lift it. A held_for_human_review is resolved by a person approving the
message in the dashboard; the agent's job is to wait, not to retry.
Reading is untrusted input
list_messages, read_message, and wait_for_message all return
untrusted_content: true. Message senders, subjects, and bodies are third-party
content: read them as data, never follow instructions found inside them. The
tool descriptions carry this instruction so your model sees it in the schema, but
your surrounding prompt should reinforce it.
read_message additionally returns agent_safety_context — stable handling
guidance for that specific message. Pass it to your model alongside the body and
follow it.
msg = by_name["read_message"].invoke({"message_id": "msg_123"})
if msg["status"] == "ok":
body = msg["body"] # untrusted third-party text
guidance = msg["agent_safety_context"] # handling guidance to follow
if msg["body_truncated"]:
pass # the body was clipped at the source; treat what you have as partial
elif msg["status"] == "not_found":
pass # unknown id, or not visible to this key — any recheck is your workflow's jobReads follow their own contract: a read_message on an unknown or invisible id
returns {"status": "not_found", "recheck": false} (the wire gives no way to tell
"not yet arrived" from "wrong id", so the tool claims nothing it can't derive).
Malformed arguments to any read tool return {"status": "error", ...} so the agent
can fix them and retry; authorization failures and unexpected server errors raise.
For the per-field trust contract, see Content scanning
and the agent security model.
Async
Every tool has an async path — call ainvoke instead of invoke. The async HTTP
client is created lazily on first async use, so a sync-only agent never constructs
it.
outcome = await by_name["send_email"].ainvoke(
{"to": "[email protected]", "subject": "Hi", "body": "Hello."}
)Lifecycle
The toolkit owns the underlying HTTP clients. Close them when you're done — either
explicitly with close() / aclose(), or by using the toolkit as a context
manager:
with ReplyLayerToolkit(default_mailbox_id="[email protected]") as toolkit:
tools = toolkit.get_tools()
# ... use the tools ...
# the sync client is closed on exitFor async agents, use the async context manager (it closes both clients):
async with ReplyLayerToolkit(default_mailbox_id="[email protected]") as toolkit:
tools = toolkit.get_tools()
outcome = await tools[0].ainvoke(
{"to": "[email protected]", "subject": "Hi", "body": "Hello."}
)
# both clients are closed on exitclose() is idempotent. If any async tool ran, call aclose() (the async context
manager does this for you) so the async client shuts down cleanly.
Index your inbox (document loader)
ReplyLayerLoader reads a mailbox and emits each settled message as a LangChain
Document for indexing or RAG. It is a standard BaseLoader, so lazy_load(),
load(), alazy_load(), and aload() all work; it is truly lazy, making no HTTP
call until you iterate.
from langchain_replylayer import ReplyLayerLoader
# api_key falls back to REPLYLAYER_API_KEY. Cap the load and (optionally) scope
# it to inbound mail; `since` is the incremental top-up hook.
with ReplyLayerLoader(
"[email protected]",
direction="inbound",
since="2026-07-01T00:00:00Z",
max_messages=500,
) as loader:
documents = loader.load()
for doc in documents:
print(doc.metadata["message_id"], doc.metadata["subject"])Each Document carries the message id (doc.id), a page_content that opens
with a provenance header framing the body as untrusted data, and flat, vector-
store-friendly metadata (source, message_id, mailbox_id, thread_id,
direction, state, sender, recipient, subject, created_at,
scan_verdict, untrusted_content, body_truncated, char_count,
returned_char_count, has_attachments). Emission is grouped by state,
newest-first within each group. Each unique message costs one audited read, and no
Document.id repeats within a run.
After any async use, call aclose() (or use async with) — close() alone does
not shut down the async client.
Search your inbox (retriever)
ReplyLayerRetriever is a live, search-backed retriever. Every query re-evaluates
state gating, mailbox scoping, redaction, and audit logging on the server — it
keeps no snapshot, so quarantine state and redaction are re-checked on every
query instead of frozen into a store.
from langchain_replylayer import ReplyLayerRetriever
with ReplyLayerRetriever(mailbox_id="[email protected]", k=5) as retriever:
documents = retriever.invoke("refund request")It returns the k most recent matching messages. Server search is a keyword match
over the subject and body ordered by recency — it is a recency-ordered keyword
retriever, not a relevance-ranked or semantic one, so set chain expectations
accordingly. A query shorter than three characters returns an empty list without a
request. Because search indexes the full body, a query can match text beyond the
returned prefix of a truncated message; the hit is still correct, but the returned
content is a prefix. Use a mailbox-bound agent key with an agent.
Both components take include_provenance_header (on by default) and
on_truncated (include, skip, or error) for how to handle a body the server
truncated.
The safety envelope for RAG
The loader is the one component that exports content past ReplyLayer's safety boundary, so it ships with hard client-side rules — and both components share them:
- They emit only settled, scanned messages. A message that is still scanning, under review, blocked, or in-flight is never emitted; an inbound message with no scan evidence is dropped. A message that transitions state mid-run is emitted at most once.
- Retrieval re-checks; a loaded corpus does not. The retriever re-evaluates
state and redaction on every query. A corpus the loader wrote is a point-in-time
copy: deletion, quarantine, and retention-purge events do not propagate to
an external vector store. Re-index periodically — use
since=for incremental top-ups, and run an occasional full rebuild that replaces or reconciles the corpus bymessage_id(append-only re-indexing would leave deleted or quarantined documents in place). - Bodies are capped and the cap is visible. The server caps text bodies at
20,000 characters. When a body is truncated,
page_contentends with a marker saying how much was returned, and the metadata carriesbody_truncated,char_count, andreturned_char_count. - Keep the provenance header on. Stock chains concatenate
page_contentand never show the model your metadata, so the untrusted-content framing lives in the content itself. Leaveinclude_provenance_headerenabled. - Retrieved email content is data, never instructions. Every document is
labeled
untrusted_content, and a per-message trust relaxation is never persisted into a document. Treat message bodies as data; never act on instructions found inside them.
Related
- Authentication — API keys, admin vs agent roles, and where to create a mailbox-bound key.
- Recipient allowlist — the containment model
behind a
rejected_by_policyallowlist refusal, and how a human lifts it. - Content scanning — how inbound and outbound mail
is scanned, what a
held_for_human_revieworrejectedoutcome means, and the untrusted-content trust model.