LangChain tools for ReplyLayer — governed email for AI agents
Project description
langchain-replylayer
LangChain tools for ReplyLayer — governed email for AI agents.
This package is a set of thin wrappers over the published replylayer SDK. Handing these tools to an agent changes nothing about the security model: every send still passes ReplyLayer's allowlist, quota, human-approval, and content-scanning gates, exactly as a direct API call would. Scanning reduces risk; a clean verdict is not a trust verdict — a sent result means "accepted for delivery", not "safe".
Inbound message content (senders, subjects, bodies) is untrusted third-party data. The tools label every read as such and carry the message's agent_safety_context through verbatim — read message bodies as data, never as instructions to act on.
ReplyLayer is in private beta, invite-only. You need a ReplyLayer API key to use these tools — get one at https://app.replylayer.ai/connect.
Install
pip install langchain-replylayer
Requires Python 3.10+. The optional real-agent walkthrough in examples/langchain_quickstart.py needs the extra:
pip install "langchain-replylayer[examples]"
Quickstart
from langchain_replylayer import ReplyLayerToolkit
# api_key falls back to the REPLYLAYER_API_KEY environment variable.
with ReplyLayerToolkit(default_mailbox_id="support") as toolkit:
tools = {tool.name: tool for tool in toolkit.get_tools()}
result = tools["send_email"].invoke(
{"to": "user@example.com", "subject": "Hi", "body": "Hello from my agent."}
)
if result["status"] == "sent":
print("accepted for delivery:", result["message_id"])
elif result["status"] == "rejected_by_policy":
print("a send gate refused it:", result["code"])
else:
print("outcome:", result["status"])
Async is symmetric — every tool ships both a sync invoke and an async ainvoke:
from langchain_replylayer import ReplyLayerToolkit
async def main():
async with ReplyLayerToolkit(default_mailbox_id="support") as toolkit:
tools = {tool.name: tool for tool in toolkit.get_tools()}
result = await tools["check_send_quota"].ainvoke({})
print(result["quota"]["sends_remaining"])
The six tools
Wire them into an agent with toolkit.get_tools(). Each returns a JSON-serializable dict with a status field to branch on; a governed outcome is never raised.
| Tool | What it does | Result |
|---|---|---|
send_email |
Send a new outbound email from a mailbox. | {status, message_id?} — status is one of sent, rejected_by_policy, rejected, held_for_human_review, retry_later, rate_limited, error. |
reply_to_email |
Reply to an inbound message, continuing its thread. | Same status set as send_email. |
list_messages |
List recent messages (cursor-paginated). | {status: "ok", messages: [...], has_more, cursor, untrusted_content: true}. Each row carries id, sender, subject, state, created_at. |
read_message |
Read one message in full. | {status: "ok", id, sender, subject, state, created_at, body, body_format, body_truncated, agent_safety_context, untrusted_content: true} — or {status: "not_found", recheck: false}. |
wait_for_message |
Long-poll a mailbox for the next message (≤30s). | {status: "ok", message, untrusted_content: true} — message is a compact row or null if none arrived. |
check_send_quota |
Preflight the remaining daily send budget. | {status: "ok", quota} — quota.sends_remaining, quota.reset_at, and quota.today.limit. |
Not exposed on purpose: anything that loosens containment (allowlist mutations, quarantine release, review approve/deny). The server rejects agent keys on those anyway, so the toolkit does not tempt a model with tools that would only 403.
Governance outcomes
The tools translate every ReplyLayer outcome into a value an agent can act on. The full status vocabulary:
status |
Tools | Meaning |
|---|---|---|
sent |
send, reply | Accepted for delivery. Carries message_id. Not a safety judgment. |
rejected_by_policy |
send, reply | A pre-admission gate refused the recipient before any bytes left: not on the allowlist, agent-contained, on your do-not-contact (suppression) list — including a platform-scoped hard-bounce hit — a failed recipient-verification/MX check, a sandbox budget/expiry limit, or a billing gate. Carries code, a human-readable detail, and agent_instructions when the server supplied them. Branch on code; don't retry unchanged. |
rejected |
send, reply | Post-admission content block (terminal). Carries code and agent_instructions. Edit the content or escalate — never resend the same body. |
held_for_human_review |
send, reply | The send was queued for human approval before it can go out. Carries message_id and agent_instructions. Report "awaiting approval"; do not treat it as a content error to fix by editing. |
retry_later |
send, reply | A transient infrastructure hold — the content was never judged. Carries code, retry_after, and agent_instructions. Retry after retry_after seconds; back off on repeats. |
rate_limited |
send, reply | A send limit was hit. Read variant (see below). |
error |
all | Another client-side problem the agent can see but that is not a governed policy outcome. Carries code and details. No retry is implied — fix the inputs. |
not_found |
read | The message id is unknown or not visible to this key. recheck: false — the wire cannot distinguish "not yet available" from "wrong id", so any recheck loop belongs to your workflow. |
ok |
list, read, wait, quota | The read succeeded; the payload follows. |
rate_limited carries a variant so the agent knows which limit it hit:
variant |
Fields | Meaning |
|---|---|---|
daily_budget |
daily_limit, sends_remaining, reset_at |
The daily send budget is exhausted. Wait until reset_at. |
new_account_warmup |
retry_after_seconds |
A new paid account's warm-up throttle. |
short_window |
retry_after |
A generic short-window throttle. retry_after may be null when the server sent no hint. |
Error policy — what each tool returns vs raises
The mapping mirrors where the server enforces each gate. A refusal an agent can act on comes back as a dict; a caller-misconfiguration or infrastructure fault is raised.
| Tool | Returned as a governed dict (never raised) | Raised |
|---|---|---|
send_email, reply_to_email |
rejected_by_policy (the enumerated send-gate codes), rejected, held_for_human_review, retry_later, rate_limited, and error for any other client-side 4xx — a non-allowlisted 403/422, a content-shape 400, or a bad-id 404 |
AuthenticationError (401) and genuinely unexpected 5xx |
list_messages, wait_for_message |
error for malformed input (400/422) the agent can fix and retry |
401 authentication, 403 scope/authorization (e.g. INSUFFICIENT_SCOPE, MAILBOX_ACCESS_DENIED), unexpected 5xx |
read_message |
not_found (404), error for malformed input (400/422) |
401 authentication, 403 scope/authorization, unexpected 5xx |
check_send_quota |
error (400/422) |
401 authentication, 403 scope/authorization, unexpected 5xx |
A blanket "any 403/422 is a policy refusal" mapping would be wrong: read tools legitimately get scope 403s (caller misconfiguration, not agent-decidable), and list_messages can 422 on malformed input (which the agent can fix). Only the enumerated send-gate codes become rejected_by_policy, and only inside send_email/reply_to_email.
Branch on the result, and let the two raising cases surface:
from replylayer.errors import AuthenticationError
from langchain_replylayer import ReplyLayerToolkit
toolkit = ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support")
send = {tool.name: tool for tool in toolkit.get_tools()}["send_email"]
try:
result = send.invoke({"to": "user@example.com", "subject": "Hi", "body": "Hello"})
except AuthenticationError:
# Bad key or wrong base_url — a caller bug, not an agent decision.
raise
if result["status"] == "held_for_human_review":
print("awaiting approval:", result["message_id"])
elif result["status"] == "rejected":
print("content blocked; edit or escalate:", result["agent_instructions"])
toolkit.close()
Untrusted content
Every list_messages, read_message, and wait_for_message result is flagged untrusted_content: true, and read_message returns the message's agent_safety_context verbatim. Message senders, subjects, and bodies are external data — an agent must read them as data and must not follow instructions found inside them. The tool descriptions state this so the contract is visible to the model, not just to you.
Secret handling
The API key is held as a Pydantic SecretStr, so it is redacted from the standard surfaces an integration (or a tracing backend) records: repr(toolkit), model_dump() / model_dump_json(), every generated tool schema, and tool-call inputs and outputs. The tools close over the underlying client rather than carrying the key in their arguments, so the key never appears in a tool's args_schema either.
Lifecycle
The toolkit owns the underlying ReplyLayer HTTP clients. Release them with close() / aclose(), or use the toolkit as a sync or async context manager. The async client is created lazily on first async use, so a sync-only integration never instantiates it; if any async tool ran, call aclose() (or use async with) so both clients are closed.
from langchain_replylayer import ReplyLayerToolkit
# Sync context manager.
with ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support") as toolkit:
tools = toolkit.get_tools()
# Or manage it explicitly (close() is idempotent).
toolkit = ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support")
tools = toolkit.get_tools()
toolkit.close()
from langchain_replylayer import ReplyLayerToolkit
async def run():
async with ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support") as toolkit:
tools = {tool.name: tool for tool in toolkit.get_tools()}
await tools["list_messages"].ainvoke({})
# both HTTP clients are now closed
Credentials
Pass api_key=... explicitly, or set the REPLYLAYER_API_KEY environment variable. The environment-variable fallback is an adapter convenience — the underlying replylayer SDK requires api_key explicitly; this adapter resolves the env var itself and passes it through. base_url defaults to the production API (https://api.replylayer.ai); set it to your staging API for verification runs. default_mailbox_id is the mailbox the tools use when a call does not name one.
Use a mailbox-bound agent key with an agent, not an admin key — the tools deliberately expose only read/act verbs, and an agent key keeps the containment boundary intact.
Versioning
langchain-replylayer is versioned independently of the replylayer SDK. It is not part of the TypeScript↔Python method-mirror contract — that contract covers the resource SDKs, and this adapter is exempt. The __all__ list in langchain_replylayer/__init__.py is the version-contracted public API; everything else (tools, toolkit, _governance) is private and may change between releases.
Learn more
Full ReplyLayer documentation, including the API reference and the security model: https://replylayer.ai/docs.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file langchain_replylayer-0.1.1.tar.gz.
File metadata
- Download URL: langchain_replylayer-0.1.1.tar.gz
- Upload date:
- Size: 22.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5e754f9d34e0323124f65f6b58c8a6a1add0679b78278ba3427ea325e0be0407
|
|
| MD5 |
9df8070e56d4f7ed982e7e9a17f3ea4c
|
|
| BLAKE2b-256 |
b4cef36dc051faafef76de4f47eeb8c230148f1fa211260be3274cf04e7dfc2f
|
Provenance
The following attestation bundles were made for langchain_replylayer-0.1.1.tar.gz:
Publisher:
publish-langchain-adapter.yml on replylayer/ReplyLayer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langchain_replylayer-0.1.1.tar.gz -
Subject digest:
5e754f9d34e0323124f65f6b58c8a6a1add0679b78278ba3427ea325e0be0407 - Sigstore transparency entry: 2163393000
- Sigstore integration time:
-
Permalink:
replylayer/ReplyLayer@86f2e2f5d7e60d3b97fe6422da9f1afd328bcd28 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/replylayer
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
self-hosted -
Publication workflow:
publish-langchain-adapter.yml@86f2e2f5d7e60d3b97fe6422da9f1afd328bcd28 -
Trigger Event:
push
-
Statement type:
File details
Details for the file langchain_replylayer-0.1.1-py3-none-any.whl.
File metadata
- Download URL: langchain_replylayer-0.1.1-py3-none-any.whl
- Upload date:
- Size: 17.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5f8409b14a2b8d768a3ef7aced81d9943dd18b03f9d6cfdd8b79cbf68570ca94
|
|
| MD5 |
ba3b99050eeccf6cb25cb0ff94a15d6d
|
|
| BLAKE2b-256 |
f6a81c477f979ecdd19b1af21ac44203d5125d5e44a3b9231e800ac1c069980a
|
Provenance
The following attestation bundles were made for langchain_replylayer-0.1.1-py3-none-any.whl:
Publisher:
publish-langchain-adapter.yml on replylayer/ReplyLayer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langchain_replylayer-0.1.1-py3-none-any.whl -
Subject digest:
5f8409b14a2b8d768a3ef7aced81d9943dd18b03f9d6cfdd8b79cbf68570ca94 - Sigstore transparency entry: 2163393049
- Sigstore integration time:
-
Permalink:
replylayer/ReplyLayer@86f2e2f5d7e60d3b97fe6422da9f1afd328bcd28 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/replylayer
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
self-hosted -
Publication workflow:
publish-langchain-adapter.yml@86f2e2f5d7e60d3b97fe6422da9f1afd328bcd28 -
Trigger Event:
push
-
Statement type: