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.
- langchain_replylayer-0.1.0/.gitignore +62 -0
- langchain_replylayer-0.1.0/PKG-INFO +213 -0
- langchain_replylayer-0.1.0/README.md +182 -0
- langchain_replylayer-0.1.0/examples/langchain_quickstart.py +182 -0
- langchain_replylayer-0.1.0/langchain_replylayer/__init__.py +18 -0
- langchain_replylayer-0.1.0/langchain_replylayer/_governance.py +236 -0
- langchain_replylayer-0.1.0/langchain_replylayer/py.typed +0 -0
- langchain_replylayer-0.1.0/langchain_replylayer/toolkit.py +131 -0
- langchain_replylayer-0.1.0/langchain_replylayer/tools.py +497 -0
- langchain_replylayer-0.1.0/pyproject.toml +56 -0
- langchain_replylayer-0.1.0/tests/__init__.py +0 -0
- langchain_replylayer-0.1.0/tests/test_governance.py +292 -0
- langchain_replylayer-0.1.0/tests/test_toolkit.py +147 -0
- langchain_replylayer-0.1.0/tests/test_tools.py +239 -0
|
@@ -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
|
+
]
|