langchain-replylayer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.*
5
+ *.env
6
+ !.env.example
7
+ # .claude is session-local (worktrees, local settings) EXCEPT the shared
8
+ # subagent definitions, which are repo-versioned (plays with `.claude/*`
9
+ # rather than `.claude/` so the re-include below can take effect).
10
+ .claude/*
11
+ !.claude/agents/
12
+ .antigravitycli/
13
+ *.log
14
+ coverage/
15
+ .turbo/
16
+ *.tsbuildinfo
17
+ __pycache__/
18
+ *.pyc
19
+ .pytest_cache/
20
+ .venv/
21
+ *.egg-info/
22
+ .DS_Store
23
+ # Local npm pack tarballs (W0/W4 use these for pre-publish artifact verification)
24
+ packages/cli/*.tgz
25
+ # CLI single-executable-app (SEA) build output — generated by the binary
26
+ # packaging step; the release artifacts are built fresh in CI, never committed.
27
+ packages/cli/sea-prep/
28
+ benchmarks/cost-experiment/results.json
29
+ benchmarks/cost-experiment/*results*.json
30
+
31
+ # Stray tsc output in sdk src/ — real output lives in packages/sdk/dist/
32
+ packages/sdk/src/**/*.js
33
+ packages/sdk/src/**/*.js.map
34
+ packages/sdk/src/**/*.d.ts
35
+ packages/sdk/src/**/*.d.ts.map
36
+
37
+ # Guardrail-proxy eval/opt run outputs (corpora are tracked, results are not)
38
+ services/guardrail-proxy/eval_results*.json
39
+ services/guardrail-proxy/opt_round*.json
40
+ services/guardrail-proxy/oos_*_eval.json
41
+ scripts/.smoke-byoes-fixture.env
42
+ scripts/.smoke-byoes-extras.env
43
+
44
+ # Local-only archive of stray/orphaned files we want off the working tree
45
+ # but preserved on disk for forensics. Never committed.
46
+ archive/
47
+
48
+ # Operational briefings — local operator notes, never committed.
49
+ docs/briefings/
50
+ plans/fault-briefings/
51
+
52
+ # Allow eval-baseline logs as audit-trail evidence
53
+ !services/guardrail-proxy/eval_baselines/*.log
54
+
55
+ # Allow stress-test scenario evidence (stdout.log + proxy-log-excerpt.log)
56
+ # under audits/ — these are signed-verdict-anchored evidence per
57
+ # plans/granite-guardian-failover-saturation-stress-test.md §11.
58
+ !audits/**/*.log
59
+ uat-grading-results*.json
60
+
61
+ # Scanner eval run outputs (corpora are tracked, results are not)
62
+ packages/scanner/src/eval/*-results.json
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-replylayer
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for ReplyLayer — governed email for AI agents
5
+ Project-URL: Homepage, https://replylayer.ai
6
+ Project-URL: Documentation, https://replylayer.ai/docs/guides/langchain
7
+ Project-URL: Repository, https://github.com/replylayer/rly
8
+ Project-URL: Issues, https://github.com/replylayer/rly/issues
9
+ License-Expression: MIT
10
+ Keywords: agent,ai,email,langchain,replylayer,toolkit,tools
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Communications :: Email
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: langchain-core<2.0,>=0.3.0
22
+ Requires-Dist: replylayer>=0.23.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: respx>=0.21; extra == 'dev'
27
+ Provides-Extra: examples
28
+ Requires-Dist: langchain-openai; extra == 'examples'
29
+ Requires-Dist: langchain>=1.0; extra == 'examples'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # langchain-replylayer
33
+
34
+ LangChain tools for [ReplyLayer](https://replylayer.ai) — governed email for AI agents.
35
+
36
+ This package is a set of **thin wrappers over the published [`replylayer`](https://pypi.org/project/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".
37
+
38
+ 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.
39
+
40
+ > 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>.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install langchain-replylayer
46
+ ```
47
+
48
+ Requires Python 3.10+. The optional real-agent walkthrough in `examples/langchain_quickstart.py` needs the extra:
49
+
50
+ ```bash
51
+ pip install "langchain-replylayer[examples]"
52
+ ```
53
+
54
+ ## Quickstart
55
+
56
+ ```python
57
+ from langchain_replylayer import ReplyLayerToolkit
58
+
59
+ # api_key falls back to the REPLYLAYER_API_KEY environment variable.
60
+ with ReplyLayerToolkit(default_mailbox_id="support") as toolkit:
61
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
62
+
63
+ result = tools["send_email"].invoke(
64
+ {"to": "user@example.com", "subject": "Hi", "body": "Hello from my agent."}
65
+ )
66
+
67
+ if result["status"] == "sent":
68
+ print("accepted for delivery:", result["message_id"])
69
+ elif result["status"] == "rejected_by_policy":
70
+ print("a send gate refused it:", result["code"])
71
+ else:
72
+ print("outcome:", result["status"])
73
+ ```
74
+
75
+ Async is symmetric — every tool ships both a sync `invoke` and an async `ainvoke`:
76
+
77
+ ```python
78
+ from langchain_replylayer import ReplyLayerToolkit
79
+
80
+ async def main():
81
+ async with ReplyLayerToolkit(default_mailbox_id="support") as toolkit:
82
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
83
+ result = await tools["check_send_quota"].ainvoke({})
84
+ print(result["quota"]["sends_remaining"])
85
+ ```
86
+
87
+ ## The six tools
88
+
89
+ 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.
90
+
91
+ | Tool | What it does | Result |
92
+ |------|--------------|--------|
93
+ | `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`. |
94
+ | `reply_to_email` | Reply to an inbound message, continuing its thread. | Same `status` set as `send_email`. |
95
+ | `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`. |
96
+ | `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}`. |
97
+ | `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. |
98
+ | `check_send_quota` | Preflight the remaining daily send budget. | `{status: "ok", quota}` — `quota.sends_remaining`, `quota.reset_at`, and `quota.today.limit`. |
99
+
100
+ 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.
101
+
102
+ ## Governance outcomes
103
+
104
+ The tools translate every ReplyLayer outcome into a value an agent can act on. The full `status` vocabulary:
105
+
106
+ | `status` | Tools | Meaning |
107
+ |----------|-------|---------|
108
+ | `sent` | send, reply | Accepted for delivery. Carries `message_id`. Not a safety judgment. |
109
+ | `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. |
110
+ | `rejected` | send, reply | Post-admission **content** block (terminal). Carries `code` and `agent_instructions`. Edit the content or escalate — never resend the same body. |
111
+ | `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. |
112
+ | `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. |
113
+ | `rate_limited` | send, reply | A send limit was hit. Read `variant` (see below). |
114
+ | `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. |
115
+ | `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. |
116
+ | `ok` | list, read, wait, quota | The read succeeded; the payload follows. |
117
+
118
+ `rate_limited` carries a `variant` so the agent knows which limit it hit:
119
+
120
+ | `variant` | Fields | Meaning |
121
+ |-----------|--------|---------|
122
+ | `daily_budget` | `daily_limit`, `sends_remaining`, `reset_at` | The daily send budget is exhausted. Wait until `reset_at`. |
123
+ | `new_account_warmup` | `retry_after_seconds` | A new paid account's warm-up throttle. |
124
+ | `short_window` | `retry_after` | A generic short-window throttle. `retry_after` may be `null` when the server sent no hint. |
125
+
126
+ ## Error policy — what each tool returns vs raises
127
+
128
+ 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.
129
+
130
+ | Tool | Returned as a governed dict (never raised) | Raised |
131
+ |------|--------------------------------------------|--------|
132
+ | `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` |
133
+ | `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` |
134
+ | `read_message` | `not_found` (`404`), `error` for malformed input (`400`/`422`) | `401` authentication, `403` scope/authorization, unexpected `5xx` |
135
+ | `check_send_quota` | `error` (`400`/`422`) | `401` authentication, `403` scope/authorization, unexpected `5xx` |
136
+
137
+ A blanket "any 403/422 is a policy refusal" mapping would be wrong: read tools legitimately get scope `403`s (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`.
138
+
139
+ Branch on the result, and let the two raising cases surface:
140
+
141
+ ```python
142
+ from replylayer.errors import AuthenticationError
143
+ from langchain_replylayer import ReplyLayerToolkit
144
+
145
+ toolkit = ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support")
146
+ send = {tool.name: tool for tool in toolkit.get_tools()}["send_email"]
147
+
148
+ try:
149
+ result = send.invoke({"to": "user@example.com", "subject": "Hi", "body": "Hello"})
150
+ except AuthenticationError:
151
+ # Bad key or wrong base_url — a caller bug, not an agent decision.
152
+ raise
153
+
154
+ if result["status"] == "held_for_human_review":
155
+ print("awaiting approval:", result["message_id"])
156
+ elif result["status"] == "rejected":
157
+ print("content blocked; edit or escalate:", result["agent_instructions"])
158
+
159
+ toolkit.close()
160
+ ```
161
+
162
+ ## Untrusted content
163
+
164
+ 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.
165
+
166
+ ## Secret handling
167
+
168
+ 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.
169
+
170
+ ## Lifecycle
171
+
172
+ 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.
173
+
174
+ ```python
175
+ from langchain_replylayer import ReplyLayerToolkit
176
+
177
+ # Sync context manager.
178
+ with ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support") as toolkit:
179
+ tools = toolkit.get_tools()
180
+
181
+ # Or manage it explicitly (close() is idempotent).
182
+ toolkit = ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support")
183
+ tools = toolkit.get_tools()
184
+ toolkit.close()
185
+ ```
186
+
187
+ ```python
188
+ from langchain_replylayer import ReplyLayerToolkit
189
+
190
+ async def run():
191
+ async with ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support") as toolkit:
192
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
193
+ await tools["list_messages"].ainvoke({})
194
+ # both HTTP clients are now closed
195
+ ```
196
+
197
+ ## Credentials
198
+
199
+ 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.
200
+
201
+ 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.
202
+
203
+ ## Versioning
204
+
205
+ `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.
206
+
207
+ ## Learn more
208
+
209
+ Full ReplyLayer documentation, including the API reference and the security model: <https://replylayer.ai/docs>.
210
+
211
+ ## License
212
+
213
+ MIT
@@ -0,0 +1,182 @@
1
+ # langchain-replylayer
2
+
3
+ LangChain tools for [ReplyLayer](https://replylayer.ai) — governed email for AI agents.
4
+
5
+ This package is a set of **thin wrappers over the published [`replylayer`](https://pypi.org/project/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".
6
+
7
+ 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.
8
+
9
+ > 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>.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install langchain-replylayer
15
+ ```
16
+
17
+ Requires Python 3.10+. The optional real-agent walkthrough in `examples/langchain_quickstart.py` needs the extra:
18
+
19
+ ```bash
20
+ pip install "langchain-replylayer[examples]"
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```python
26
+ from langchain_replylayer import ReplyLayerToolkit
27
+
28
+ # api_key falls back to the REPLYLAYER_API_KEY environment variable.
29
+ with ReplyLayerToolkit(default_mailbox_id="support") as toolkit:
30
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
31
+
32
+ result = tools["send_email"].invoke(
33
+ {"to": "user@example.com", "subject": "Hi", "body": "Hello from my agent."}
34
+ )
35
+
36
+ if result["status"] == "sent":
37
+ print("accepted for delivery:", result["message_id"])
38
+ elif result["status"] == "rejected_by_policy":
39
+ print("a send gate refused it:", result["code"])
40
+ else:
41
+ print("outcome:", result["status"])
42
+ ```
43
+
44
+ Async is symmetric — every tool ships both a sync `invoke` and an async `ainvoke`:
45
+
46
+ ```python
47
+ from langchain_replylayer import ReplyLayerToolkit
48
+
49
+ async def main():
50
+ async with ReplyLayerToolkit(default_mailbox_id="support") as toolkit:
51
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
52
+ result = await tools["check_send_quota"].ainvoke({})
53
+ print(result["quota"]["sends_remaining"])
54
+ ```
55
+
56
+ ## The six tools
57
+
58
+ 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.
59
+
60
+ | Tool | What it does | Result |
61
+ |------|--------------|--------|
62
+ | `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`. |
63
+ | `reply_to_email` | Reply to an inbound message, continuing its thread. | Same `status` set as `send_email`. |
64
+ | `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`. |
65
+ | `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}`. |
66
+ | `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. |
67
+ | `check_send_quota` | Preflight the remaining daily send budget. | `{status: "ok", quota}` — `quota.sends_remaining`, `quota.reset_at`, and `quota.today.limit`. |
68
+
69
+ 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.
70
+
71
+ ## Governance outcomes
72
+
73
+ The tools translate every ReplyLayer outcome into a value an agent can act on. The full `status` vocabulary:
74
+
75
+ | `status` | Tools | Meaning |
76
+ |----------|-------|---------|
77
+ | `sent` | send, reply | Accepted for delivery. Carries `message_id`. Not a safety judgment. |
78
+ | `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. |
79
+ | `rejected` | send, reply | Post-admission **content** block (terminal). Carries `code` and `agent_instructions`. Edit the content or escalate — never resend the same body. |
80
+ | `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. |
81
+ | `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. |
82
+ | `rate_limited` | send, reply | A send limit was hit. Read `variant` (see below). |
83
+ | `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. |
84
+ | `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. |
85
+ | `ok` | list, read, wait, quota | The read succeeded; the payload follows. |
86
+
87
+ `rate_limited` carries a `variant` so the agent knows which limit it hit:
88
+
89
+ | `variant` | Fields | Meaning |
90
+ |-----------|--------|---------|
91
+ | `daily_budget` | `daily_limit`, `sends_remaining`, `reset_at` | The daily send budget is exhausted. Wait until `reset_at`. |
92
+ | `new_account_warmup` | `retry_after_seconds` | A new paid account's warm-up throttle. |
93
+ | `short_window` | `retry_after` | A generic short-window throttle. `retry_after` may be `null` when the server sent no hint. |
94
+
95
+ ## Error policy — what each tool returns vs raises
96
+
97
+ 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.
98
+
99
+ | Tool | Returned as a governed dict (never raised) | Raised |
100
+ |------|--------------------------------------------|--------|
101
+ | `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` |
102
+ | `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` |
103
+ | `read_message` | `not_found` (`404`), `error` for malformed input (`400`/`422`) | `401` authentication, `403` scope/authorization, unexpected `5xx` |
104
+ | `check_send_quota` | `error` (`400`/`422`) | `401` authentication, `403` scope/authorization, unexpected `5xx` |
105
+
106
+ A blanket "any 403/422 is a policy refusal" mapping would be wrong: read tools legitimately get scope `403`s (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`.
107
+
108
+ Branch on the result, and let the two raising cases surface:
109
+
110
+ ```python
111
+ from replylayer.errors import AuthenticationError
112
+ from langchain_replylayer import ReplyLayerToolkit
113
+
114
+ toolkit = ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support")
115
+ send = {tool.name: tool for tool in toolkit.get_tools()}["send_email"]
116
+
117
+ try:
118
+ result = send.invoke({"to": "user@example.com", "subject": "Hi", "body": "Hello"})
119
+ except AuthenticationError:
120
+ # Bad key or wrong base_url — a caller bug, not an agent decision.
121
+ raise
122
+
123
+ if result["status"] == "held_for_human_review":
124
+ print("awaiting approval:", result["message_id"])
125
+ elif result["status"] == "rejected":
126
+ print("content blocked; edit or escalate:", result["agent_instructions"])
127
+
128
+ toolkit.close()
129
+ ```
130
+
131
+ ## Untrusted content
132
+
133
+ 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.
134
+
135
+ ## Secret handling
136
+
137
+ 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.
138
+
139
+ ## Lifecycle
140
+
141
+ 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.
142
+
143
+ ```python
144
+ from langchain_replylayer import ReplyLayerToolkit
145
+
146
+ # Sync context manager.
147
+ with ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support") as toolkit:
148
+ tools = toolkit.get_tools()
149
+
150
+ # Or manage it explicitly (close() is idempotent).
151
+ toolkit = ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support")
152
+ tools = toolkit.get_tools()
153
+ toolkit.close()
154
+ ```
155
+
156
+ ```python
157
+ from langchain_replylayer import ReplyLayerToolkit
158
+
159
+ async def run():
160
+ async with ReplyLayerToolkit(api_key="rly_...", default_mailbox_id="support") as toolkit:
161
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
162
+ await tools["list_messages"].ainvoke({})
163
+ # both HTTP clients are now closed
164
+ ```
165
+
166
+ ## Credentials
167
+
168
+ 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.
169
+
170
+ 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.
171
+
172
+ ## Versioning
173
+
174
+ `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.
175
+
176
+ ## Learn more
177
+
178
+ Full ReplyLayer documentation, including the API reference and the security model: <https://replylayer.ai/docs>.
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """ReplyLayer + LangChain quickstart — governed email tools for an agent.
3
+
4
+ Run this against a STAGING sandbox with a mailbox-bound *agent* API key, never a
5
+ production or admin key: the tools send real email through every ReplyLayer gate,
6
+ and the point of the walkthrough is to watch those gates fire. Scanning reduces
7
+ risk; a clean verdict is not a trust verdict.
8
+
9
+ Environment
10
+ -----------
11
+ REPLYLAYER_API_KEY (required) a staging sandbox, mailbox-bound agent key.
12
+ REPLYLAYER_MAILBOX (required) the sending mailbox id or name.
13
+ REPLYLAYER_BASE_URL (optional) point this at your STAGING API for a verification
14
+ run; defaults to the documented production base.
15
+ REPLYLAYER_TO (optional) recipient to try; defaults to an address that is
16
+ (deliberately) unlikely to be on the allowlist,
17
+ so the refusal branch actually runs.
18
+ OPENAI_API_KEY (optional) used only by the final, optional real-agent
19
+ section, which needs the `[examples]` extra.
20
+
21
+ pip install "langchain-replylayer" # the six tools
22
+ pip install "langchain-replylayer[examples]" # + the real-agent section
23
+
24
+ python examples/langchain_quickstart.py
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import os
29
+
30
+ from replylayer.errors import AuthenticationError
31
+
32
+ from langchain_replylayer import ReplyLayerToolkit
33
+
34
+ # base_url is parameterized — set REPLYLAYER_BASE_URL to your staging API for a
35
+ # verification run; it defaults to the documented production base.
36
+ BASE_URL = os.environ.get("REPLYLAYER_BASE_URL", "https://api.replylayer.ai")
37
+ MAILBOX = os.environ.get("REPLYLAYER_MAILBOX", "")
38
+ TO = os.environ.get("REPLYLAYER_TO", "not-on-your-allowlist@example.com")
39
+
40
+ # A recent-message scan is bounded — never walk the whole mailbox.
41
+ MAX_ROWS_TO_SCAN = 5
42
+
43
+
44
+ def demo_send(tools: dict) -> None:
45
+ """Send once and branch on the governed outcome instead of catching an error.
46
+
47
+ A brand-new sandbox mailbox defaults to `allowlist` mode, so an off-list
48
+ recipient is refused BEFORE any bytes leave — surfaced as
49
+ `{status: "rejected_by_policy", code: "RECIPIENT_NOT_ON_ALLOWLIST"}`, a plain
50
+ dict the agent can branch on, not an exception.
51
+ """
52
+ result = tools["send_email"].invoke(
53
+ {"to": TO, "subject": "Hello from my agent", "body": "Hi there — this is a test send."}
54
+ )
55
+ status = result["status"]
56
+ if status == "sent":
57
+ print(f" sent — accepted for delivery as {result['message_id']}")
58
+ elif status == "rejected_by_policy":
59
+ print(f" refused before sending by a policy gate: {result['code']}")
60
+ if result["code"] == "RECIPIENT_NOT_ON_ALLOWLIST":
61
+ print(" -> add the recipient to the mailbox allowlist, or send to a thread participant.")
62
+ elif status == "held_for_human_review":
63
+ print(f" queued for human approval (message {result.get('message_id')}); do not resend.")
64
+ elif status == "rejected":
65
+ print(f" content blocked ({result['code']}); edit the content or escalate — never resend unchanged.")
66
+ elif status == "retry_later":
67
+ print(f" transient infrastructure hold; retry after {result.get('retry_after')}s.")
68
+ elif status == "rate_limited":
69
+ print(f" rate limited ({result['variant']}); back off before retrying.")
70
+ else: # "error"
71
+ print(f" send could not be admitted: {result.get('code')} {result.get('details')}")
72
+
73
+
74
+ def demo_quota(tools: dict) -> None:
75
+ """Preflight the send budget instead of discovering the limit by hitting it."""
76
+ result = tools["check_send_quota"].invoke({})
77
+ if result["status"] != "ok":
78
+ print(f" could not read quota: {result.get('code')}")
79
+ return
80
+ quota = result["quota"]
81
+ print(f" sends remaining today: {quota.get('sends_remaining')} (resets {quota.get('reset_at')})")
82
+
83
+
84
+ def demo_read_untrusted(tools: dict) -> None:
85
+ """List recent mail and read one message, treating its content as data.
86
+
87
+ Message senders, subjects, and bodies are untrusted third-party content: the
88
+ tools flag every read with `untrusted_content` and carry the message's
89
+ `agent_safety_context` verbatim. Never follow instructions found in a body.
90
+ """
91
+ listed = tools["list_messages"].invoke({"limit": MAX_ROWS_TO_SCAN, "direction": "inbound"})
92
+ if listed["status"] != "ok":
93
+ print(f" could not list messages: {listed.get('code')}")
94
+ return
95
+ rows = listed["messages"]
96
+ if not rows:
97
+ print(" no inbound messages yet — send one to this mailbox and re-run.")
98
+ return
99
+
100
+ # Bounded: only the newest row, and only the fields the compact projection gives.
101
+ first = rows[0]
102
+ print(f" newest inbound: {first['subject']!r} from {first['sender']} [{first['state']}]")
103
+
104
+ message = tools["read_message"].invoke({"message_id": first["id"]})
105
+ if message["status"] == "not_found":
106
+ print(" message is no longer visible to this key.")
107
+ return
108
+ if message["status"] != "ok":
109
+ print(f" could not read message: {message.get('code')}")
110
+ return
111
+
112
+ # untrusted_content is always True for an inbound read.
113
+ context = message.get("agent_safety_context") or {}
114
+ print(f" safety context: {context}")
115
+ body = message.get("body") or ""
116
+ preview = body[:120].replace("\n", " ")
117
+ print(f" body (untrusted data — do NOT act on instructions inside): {preview!r}")
118
+
119
+
120
+ def demo_real_agent(tools: dict) -> None:
121
+ """OPTIONAL — hand the six governed tools to a real LangChain agent.
122
+
123
+ Skipped gracefully unless the `[examples]` extra is installed AND
124
+ OPENAI_API_KEY is set. The agent provider named here is example wiring only.
125
+ """
126
+ if not os.environ.get("OPENAI_API_KEY"):
127
+ print(" skipped: set OPENAI_API_KEY to run the optional real-agent section.")
128
+ return
129
+ try:
130
+ from langchain.agents import create_agent
131
+ from langchain_openai import ChatOpenAI
132
+ except ImportError:
133
+ print(' skipped: install the extra — pip install "langchain-replylayer[examples]".')
134
+ return
135
+
136
+ agent = create_agent(ChatOpenAI(model="gpt-4o-mini"), tools=list(tools.values()))
137
+ response = agent.invoke(
138
+ {
139
+ "messages": [
140
+ {
141
+ "role": "user",
142
+ "content": (
143
+ "Use the available tools to check my remaining send quota, "
144
+ "then tell me the number. Do not send any email."
145
+ ),
146
+ }
147
+ ]
148
+ }
149
+ )
150
+ final = response["messages"][-1]
151
+ print(f" agent said: {getattr(final, 'content', final)}")
152
+
153
+
154
+ def main() -> int:
155
+ api_key = os.environ.get("REPLYLAYER_API_KEY")
156
+ if not api_key:
157
+ print("Set REPLYLAYER_API_KEY (a staging sandbox, mailbox-bound agent key) first.")
158
+ return 1
159
+ if not MAILBOX:
160
+ print("Set REPLYLAYER_MAILBOX to the sending mailbox id or name first.")
161
+ return 1
162
+
163
+ print(f"ReplyLayer LangChain quickstart against {BASE_URL} (mailbox: {MAILBOX})")
164
+ with ReplyLayerToolkit(api_key=api_key, base_url=BASE_URL, default_mailbox_id=MAILBOX) as toolkit:
165
+ tools = {tool.name: tool for tool in toolkit.get_tools()}
166
+ try:
167
+ print("\n1) send (watch the allowlist gate):")
168
+ demo_send(tools)
169
+ print("\n2) quota preflight:")
170
+ demo_quota(tools)
171
+ print("\n3) read a message as untrusted data:")
172
+ demo_read_untrusted(tools)
173
+ print("\n4) optional real agent:")
174
+ demo_real_agent(tools)
175
+ except AuthenticationError:
176
+ print("Authentication failed — check REPLYLAYER_API_KEY and REPLYLAYER_BASE_URL.")
177
+ return 1
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ """LangChain tools for ReplyLayer — governed email for AI agents.
2
+
3
+ Thin wrappers over the published ``replylayer`` SDK: every send still passes the
4
+ allowlist, quota, human-approval, and content-scanning gates. This package is
5
+ versioned independently of the ``replylayer`` SDK and is not part of the
6
+ TypeScript<->Python method-mirror contract.
7
+
8
+ ``__all__`` below is the version-contracted public API; everything else
9
+ (``tools``, ``toolkit``, ``_governance``) is private.
10
+ """
11
+ from .toolkit import ReplyLayerToolkit
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "ReplyLayerToolkit",
17
+ "__version__",
18
+ ]