agentloadout 0.1.1__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,68 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (.env.example is committed)
34
+ .env
35
+ .env.*
36
+ !.env.example
37
+
38
+ # vercel
39
+ .vercel
40
+
41
+ # typescript
42
+ *.tsbuildinfo
43
+ next-env.d.ts
44
+
45
+ .data/
46
+
47
+ # Private AgentPlan draft links are local publication metadata.
48
+ /output/**/*.agentplan.json
49
+
50
+ # Legacy GT output and local CLI state (active catalogs live in locales/)
51
+ /public/_gt/
52
+ /.gt/
53
+
54
+ # Vite / Wrangler / TanStack Start
55
+ /dist
56
+ /.wrangler
57
+ /worker-configuration.d.ts
58
+ .dev.vars
59
+ .dev.vars.*
60
+ !.dev.vars.example
61
+
62
+ # SDK packages
63
+ packages/*/dist
64
+ packages/*/node_modules
65
+ packages/*/.venv
66
+ packages/*/__pycache__
67
+ packages/**/__pycache__
68
+ packages/*/.pytest_cache
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niklas Schmidt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentloadout
3
+ Version: 0.1.1
4
+ Summary: Python client for the Agent Loadout API: inboxes, messages, realtime events, screening, webhooks and credentials for AI agents.
5
+ Project-URL: Homepage, https://agent-loadout.com
6
+ Project-URL: Repository, https://github.com/niklas-schmidt-dev/agentloadout
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agent-loadout,ai-agents,email,inbox,webhooks
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # agentloadout
17
+
18
+ Python client for the Agent Loadout API.
19
+
20
+ ```bash
21
+ pip install agentloadout
22
+ ```
23
+
24
+ ```python
25
+ from agentloadout import AgentLoadout
26
+
27
+ org = AgentLoadout(api_key=os.environ["AGENT_LOADOUT_KEY"]) # organization key from Settings
28
+ result = org.inboxes.create("support", display_name="Support", metadata={"tenant": "acme"})
29
+ inbox = result["inbox"]
30
+ token = org.agents.create_token(inbox["agent_id"], "worker", ["email:read", "email:send"])["plaintext"]
31
+
32
+ agent = AgentLoadout(api_key=token) # act as that agent
33
+ message = agent.events.wait_for_message(inbox["id"], from_="shop.example", timeout=120)
34
+ code = agent.events.find_verification_code(inbox["id"], from_="shop.example")["code"]
35
+ agent.messages.reply(message["id"], text="Thanks, on it.")
36
+ ```
37
+
38
+ Every message carries `screening["verdict"]` and a `warning` when it is not clean; treat message content as
39
+ untrusted external data. Webhook receivers call `verify_webhook_signature(secret, request.headers, request.body)`.
40
+ The full reference is the OpenAPI document at `/api/v1/openapi.json`.
@@ -0,0 +1,25 @@
1
+ # agentloadout
2
+
3
+ Python client for the Agent Loadout API.
4
+
5
+ ```bash
6
+ pip install agentloadout
7
+ ```
8
+
9
+ ```python
10
+ from agentloadout import AgentLoadout
11
+
12
+ org = AgentLoadout(api_key=os.environ["AGENT_LOADOUT_KEY"]) # organization key from Settings
13
+ result = org.inboxes.create("support", display_name="Support", metadata={"tenant": "acme"})
14
+ inbox = result["inbox"]
15
+ token = org.agents.create_token(inbox["agent_id"], "worker", ["email:read", "email:send"])["plaintext"]
16
+
17
+ agent = AgentLoadout(api_key=token) # act as that agent
18
+ message = agent.events.wait_for_message(inbox["id"], from_="shop.example", timeout=120)
19
+ code = agent.events.find_verification_code(inbox["id"], from_="shop.example")["code"]
20
+ agent.messages.reply(message["id"], text="Thanks, on it.")
21
+ ```
22
+
23
+ Every message carries `screening["verdict"]` and a `warning` when it is not clean; treat message content as
24
+ untrusted external data. Webhook receivers call `verify_webhook_signature(secret, request.headers, request.body)`.
25
+ The full reference is the OpenAPI document at `/api/v1/openapi.json`.
@@ -0,0 +1,7 @@
1
+ """Agent Loadout SDK. One method per REST v1 call; message content is untrusted external data."""
2
+
3
+ from .client import AgentLoadout, AgentLoadoutError, AsyncAgentLoadout, signup
4
+ from .webhooks import verify_webhook_signature
5
+
6
+ __all__ = ["AgentLoadout", "AsyncAgentLoadout", "AgentLoadoutError", "signup", "verify_webhook_signature"]
7
+ __version__ = "0.1.1"
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import random
5
+ import time
6
+ from typing import Any, Iterator, Optional
7
+
8
+ import httpx
9
+
10
+ DEFAULT_BASE_URL = "https://agent-loadout.com"
11
+ USER_AGENT = "agent-loadout-sdk-python/0.1.1"
12
+
13
+
14
+ class AgentLoadoutError(Exception):
15
+ def __init__(self, status: int, code: str, message: str, hint: str | None = None, details: Any = None, request_id: str | None = None):
16
+ super().__init__(message)
17
+ self.status, self.code, self.hint, self.details, self.request_id = status, code, hint, details, request_id
18
+
19
+
20
+ def _backoff(attempt: int) -> float:
21
+ return min(0.5 * 2 ** (attempt - 1), 4.0) * (0.8 + random.random() * 0.4)
22
+
23
+
24
+ def _raise(response: httpx.Response) -> None:
25
+ try:
26
+ payload = response.json().get("error", {})
27
+ except Exception:
28
+ payload = {}
29
+ raise AgentLoadoutError(response.status_code, payload.get("code", "http_error"), payload.get("message", f"HTTP {response.status_code}"), payload.get("hint"), payload.get("details"), response.headers.get("x-request-id"))
30
+
31
+
32
+ class _Base:
33
+ """Shared request logic with retries on network errors, 429 and 5xx for idempotent calls."""
34
+
35
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, max_retries: int = 2, timeout: float = 30.0):
36
+ if not api_key:
37
+ raise ValueError("api_key is required (an alt_ agent token or alk_ organization key)")
38
+ self._headers = {"authorization": f"Bearer {api_key}", "accept": "application/json", "user-agent": USER_AGENT}
39
+ self._base_url = base_url.rstrip("/")
40
+ self._max_retries = max_retries
41
+ self._timeout = timeout
42
+
43
+ @staticmethod
44
+ def _query(params: dict[str, Any] | None) -> dict[str, str]:
45
+ out: dict[str, str] = {}
46
+ for k, v in (params or {}).items():
47
+ if v is None:
48
+ continue
49
+ if k == "metadata" and isinstance(v, dict):
50
+ for mk, mv in v.items():
51
+ out[f"metadata.{mk}"] = str(mv)
52
+ elif isinstance(v, (list, tuple)):
53
+ out[k] = ",".join(str(x) for x in v)
54
+ elif isinstance(v, bool):
55
+ out[k] = "true" if v else "false"
56
+ else:
57
+ out[k] = str(v)
58
+ return out
59
+
60
+
61
+ class AgentLoadout(_Base):
62
+ """Synchronous client."""
63
+
64
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, max_retries: int = 2, timeout: float = 30.0, client: httpx.Client | None = None):
65
+ super().__init__(api_key, base_url, max_retries, timeout)
66
+ self._client = client or httpx.Client(timeout=timeout)
67
+ self.inboxes = Inboxes(self)
68
+ self.messages = Messages(self)
69
+ self.threads = Threads(self)
70
+ self.events = Events(self)
71
+ self.sender_rules = SenderRules(self)
72
+ self.agents = Agents(self)
73
+ self.webhooks = Webhooks(self)
74
+ self.attachments = Attachments(self)
75
+ self.domains = Domains(self)
76
+
77
+ def me(self) -> dict[str, Any]:
78
+ return self._request("GET", "/api/v1/me")
79
+
80
+ def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any = None, idempotent: bool | None = None, timeout: float | None = None) -> Any:
81
+ if idempotent is None:
82
+ idempotent = method in ("GET", "DELETE") or bool(isinstance(json, dict) and json.get("idempotency_key"))
83
+ attempt = 0
84
+ while True:
85
+ attempt += 1
86
+ try:
87
+ response = self._client.request(method, self._base_url + path, params=self._query(params), json=json, headers=self._headers, timeout=timeout or self._timeout)
88
+ except httpx.HTTPError as error:
89
+ if idempotent and attempt <= self._max_retries:
90
+ time.sleep(_backoff(attempt))
91
+ continue
92
+ raise AgentLoadoutError(0, "network_error", str(error)) from error
93
+ if response.is_success:
94
+ if "application/json" in response.headers.get("content-type", ""):
95
+ return response.json()
96
+ return response.content
97
+ if (response.status_code == 429 or response.status_code >= 500) and idempotent and attempt <= self._max_retries:
98
+ retry_after = response.headers.get("retry-after")
99
+ time.sleep(float(retry_after) if retry_after and retry_after.isdigit() else _backoff(attempt))
100
+ continue
101
+ _raise(response)
102
+
103
+
104
+ class Inboxes:
105
+ def __init__(self, c: AgentLoadout):
106
+ self._c = c
107
+
108
+ def list(self, agent_id: str | None = None, metadata: dict[str, str] | None = None) -> list[dict[str, Any]]:
109
+ return self._c._request("GET", "/api/v1/inboxes", params={"agent_id": agent_id, "metadata": metadata})["inboxes"]
110
+
111
+ def get(self, inbox_id: str) -> dict[str, Any]:
112
+ return self._c._request("GET", f"/api/v1/inboxes/{inbox_id}")["inbox"]
113
+
114
+ def create(self, username: str, *, agent_id: str | None = None, display_name: str | None = None, domain: str | None = None, inbound_mode: str | None = None, metadata: dict[str, str] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
115
+ """Organization key. `inbox` is None while provisioning continues in the background."""
116
+ body = {k: v for k, v in {"username": username, "agent_id": agent_id, "display_name": display_name, "domain": domain, "inbound_mode": inbound_mode, "metadata": metadata, "idempotency_key": idempotency_key}.items() if v is not None}
117
+ return self._c._request("POST", "/api/v1/inboxes", json=body, idempotent=bool(idempotency_key))
118
+
119
+ def update(self, inbox_id: str, **patch: Any) -> dict[str, Any]:
120
+ return self._c._request("PATCH", f"/api/v1/inboxes/{inbox_id}", json=patch)["inbox"]
121
+
122
+ def delete(self, inbox_id: str) -> dict[str, Any]:
123
+ return self._c._request("DELETE", f"/api/v1/inboxes/{inbox_id}", params={"confirm": "true"})
124
+
125
+ def counts(self, inbox_id: str) -> dict[str, int]:
126
+ return self._c._request("GET", f"/api/v1/inboxes/{inbox_id}/counts")
127
+
128
+
129
+ class Messages:
130
+ def __init__(self, c: AgentLoadout):
131
+ self._c = c
132
+
133
+ def list(self, inbox_id: str, **params: Any) -> dict[str, Any]:
134
+ return self._c._request("GET", f"/api/v1/inboxes/{inbox_id}/messages", params=params)
135
+
136
+ def get(self, message_id: str) -> dict[str, Any]:
137
+ return self._c._request("GET", f"/api/v1/messages/{message_id}")["message"]
138
+
139
+ def send(self, inbox_id: str, to: list[str], subject: str, *, text: str | None = None, html: str | None = None, cc: list[str] | None = None, bcc: list[str] | None = None, idempotency_key: str | None = None, attachment_ids: list[str] | None = None, draft_id: str | None = None, reply_to: list[str] | None = None, headers: dict[str, str] | None = None, labels: list[str] | None = None) -> dict[str, Any]:
140
+ body = {k: v for k, v in {"to": to, "subject": subject, "text": text, "html": html, "cc": cc, "bcc": bcc, "idempotency_key": idempotency_key, "attachment_ids": attachment_ids, "draft_id": draft_id, "reply_to": reply_to, "headers": headers, "labels": labels}.items() if v is not None}
141
+ return self._c._request("POST", f"/api/v1/inboxes/{inbox_id}/messages", json=body)["message"]
142
+
143
+ def reply(self, message_id: str, *, text: str | None = None, html: str | None = None, reply_all: bool = False, idempotency_key: str | None = None, attachment_ids: list[str] | None = None) -> dict[str, Any]:
144
+ body = {k: v for k, v in {"text": text, "html": html, "reply_all": reply_all, "idempotency_key": idempotency_key, "attachment_ids": attachment_ids}.items() if v is not None}
145
+ return self._c._request("POST", f"/api/v1/messages/{message_id}/reply", json=body)["message"]
146
+
147
+ def forward(self, message_id: str, to: list[str], **extra: Any) -> dict[str, Any]:
148
+ return self._c._request("POST", f"/api/v1/messages/{message_id}/forward", json={"to": to, **extra})["message"]
149
+
150
+
151
+ class Threads:
152
+ def __init__(self, c: AgentLoadout):
153
+ self._c = c
154
+
155
+ def list(self, inbox_id: str, **params: Any) -> dict[str, Any]:
156
+ return self._c._request("GET", f"/api/v1/inboxes/{inbox_id}/threads", params=params)
157
+
158
+ def get(self, thread_id: str, **params: Any) -> dict[str, Any]:
159
+ return self._c._request("GET", f"/api/v1/threads/{thread_id}", params=params)
160
+
161
+ def update(self, thread_id: str, *, folder: str | None = None, read: bool | None = None, labels: list[str] | None = None, add_labels: list[str] | None = None, remove_labels: list[str] | None = None) -> dict[str, Any]:
162
+ return self._c._request("PATCH", f"/api/v1/threads/{thread_id}", json={k: v for k, v in {"folder": folder, "read": read, "labels": labels, "add_labels": add_labels, "remove_labels": remove_labels}.items() if v is not None}, idempotent=True)
163
+
164
+
165
+ class Events:
166
+ def __init__(self, c: AgentLoadout):
167
+ self._c = c
168
+
169
+ def list(self, inbox_id: str, *, cursor: str | None = None, types: list[str] | None = None, limit: int | None = None, wait: int | None = None) -> dict[str, Any]:
170
+ return self._c._request("GET", f"/api/v1/inboxes/{inbox_id}/events", params={"cursor": cursor, "types": types, "limit": limit, "wait": wait}, timeout=(wait or 0) + 30)
171
+
172
+ def subscribe(self, inbox_id: str, *, cursor: str | None = None, types: list[str] | None = None) -> Iterator[dict[str, Any]]:
173
+ """Generator that keeps long-polling; break out of the loop to stop."""
174
+ while True:
175
+ page = self.list(inbox_id, cursor=cursor, types=types, wait=25)
176
+ yield from page["events"]
177
+ cursor = page["cursor"]
178
+
179
+ def wait_for_message(self, inbox_id: str, *, from_: str | None = None, subject_contains: str | None = None, since: str | None = None, timeout: int = 60) -> Optional[dict[str, Any]]:
180
+ """Block for the next inbound message matching the filter, or None after `timeout` seconds."""
181
+ deadline = time.time() + timeout
182
+ while True:
183
+ step = max(1, min(25, int(deadline - time.time()) + 1))
184
+ body = {k: v for k, v in {"from": from_, "subject_contains": subject_contains, "since": since, "timeout": step}.items() if v is not None}
185
+ res = self._c._request("POST", f"/api/v1/inboxes/{inbox_id}/wait", json=body, idempotent=True, timeout=step + 30)
186
+ if res.get("matched") and res.get("message"):
187
+ return res["message"]
188
+ since = res["cursor"]
189
+ if time.time() >= deadline:
190
+ return None
191
+
192
+ def find_verification_code(self, inbox_id: str, *, from_: str | None = None, subject_contains: str | None = None, since: str | None = None, wait: int | None = None) -> dict[str, Any]:
193
+ """Code or link copied from the newest matching mail; never generated."""
194
+ body = {k: v for k, v in {"from": from_, "subject_contains": subject_contains, "since": since, "wait": wait}.items() if v is not None}
195
+ return self._c._request("POST", f"/api/v1/inboxes/{inbox_id}/extract/code", json=body, idempotent=True, timeout=(wait or 0) + 30)
196
+
197
+
198
+ class SenderRules:
199
+ def __init__(self, c: AgentLoadout):
200
+ self._c = c
201
+
202
+ def list(self, inbox_id: str) -> list[dict[str, Any]]:
203
+ return self._c._request("GET", f"/api/v1/inboxes/{inbox_id}/sender-rules")["rules"]
204
+
205
+ def add(self, inbox_id: str, kind: str, pattern: str, note: str | None = None) -> dict[str, Any]:
206
+ return self._c._request("POST", f"/api/v1/inboxes/{inbox_id}/sender-rules", json={"kind": kind, "pattern": pattern, **({"note": note} if note else {})}, idempotent=True)["rule"]
207
+
208
+ def remove(self, rule_id: str) -> dict[str, Any]:
209
+ return self._c._request("DELETE", f"/api/v1/sender-rules/{rule_id}")
210
+
211
+
212
+ class Agents:
213
+ def __init__(self, c: AgentLoadout):
214
+ self._c = c
215
+
216
+ def list(self, metadata: dict[str, str] | None = None) -> list[dict[str, Any]]:
217
+ return self._c._request("GET", "/api/v1/agents", params={"metadata": metadata})["agents"]
218
+
219
+ def create(self, name: str, *, description: str | None = None, metadata: dict[str, str] | None = None) -> dict[str, Any]:
220
+ return self._c._request("POST", "/api/v1/agents", json={k: v for k, v in {"name": name, "description": description, "metadata": metadata}.items() if v is not None})["agent"]
221
+
222
+ def get(self, agent_id: str) -> dict[str, Any]:
223
+ return self._c._request("GET", f"/api/v1/agents/{agent_id}")["agent"]
224
+
225
+ def update(self, agent_id: str, **patch: Any) -> dict[str, Any]:
226
+ return self._c._request("PATCH", f"/api/v1/agents/{agent_id}", json=patch)["agent"]
227
+
228
+ def list_tokens(self, agent_id: str) -> list[dict[str, Any]]:
229
+ return self._c._request("GET", f"/api/v1/agents/{agent_id}/tokens")["tokens"]
230
+
231
+ def create_token(self, agent_id: str, name: str, capabilities: list[str], expires_in_days: int | None = None) -> dict[str, Any]:
232
+ """Returns {'token': ..., 'plaintext': ...}; the plaintext is shown once."""
233
+ body = {"name": name, "capabilities": capabilities, **({"expires_in_days": expires_in_days} if expires_in_days else {})}
234
+ return self._c._request("POST", f"/api/v1/agents/{agent_id}/tokens", json=body)
235
+
236
+ def revoke_token(self, token_id: str) -> dict[str, Any]:
237
+ return self._c._request("DELETE", f"/api/v1/tokens/{token_id}")
238
+
239
+
240
+ class Webhooks:
241
+ def __init__(self, c: AgentLoadout):
242
+ self._c = c
243
+
244
+ def list(self) -> list[dict[str, Any]]:
245
+ return self._c._request("GET", "/api/v1/webhooks")["webhooks"]
246
+
247
+ def create(self, url: str, *, description: str | None = None, event_types: list[str] | None = None, inbox_id: str | None = None) -> dict[str, Any]:
248
+ """Returns {'webhook': ..., 'secret': ...}; the secret is shown once."""
249
+ return self._c._request("POST", "/api/v1/webhooks", json={k: v for k, v in {"url": url, "description": description, "event_types": event_types, "inbox_id": inbox_id}.items() if v is not None})
250
+
251
+ def get(self, webhook_id: str) -> dict[str, Any]:
252
+ return self._c._request("GET", f"/api/v1/webhooks/{webhook_id}")["webhook"]
253
+
254
+ def update(self, webhook_id: str, **patch: Any) -> dict[str, Any]:
255
+ return self._c._request("PATCH", f"/api/v1/webhooks/{webhook_id}", json=patch)["webhook"]
256
+
257
+ def delete(self, webhook_id: str) -> dict[str, Any]:
258
+ return self._c._request("DELETE", f"/api/v1/webhooks/{webhook_id}")
259
+
260
+ def test(self, webhook_id: str) -> dict[str, Any]:
261
+ return self._c._request("POST", f"/api/v1/webhooks/{webhook_id}/test")
262
+
263
+ def rotate_secret(self, webhook_id: str) -> dict[str, Any]:
264
+ return self._c._request("POST", f"/api/v1/webhooks/{webhook_id}/rotate")
265
+
266
+ def deliveries(self, webhook_id: str, limit: int | None = None) -> list[dict[str, Any]]:
267
+ return self._c._request("GET", f"/api/v1/webhooks/{webhook_id}/deliveries", params={"limit": limit})["deliveries"]
268
+
269
+ def replay(self, delivery_id: str) -> dict[str, Any]:
270
+ return self._c._request("POST", f"/api/v1/webhook-deliveries/{delivery_id}/replay")
271
+
272
+
273
+ class Domains:
274
+ def __init__(self, c: AgentLoadout):
275
+ self._c = c
276
+
277
+ def list(self) -> list[dict[str, Any]]:
278
+ return self._c._request("GET", "/api/v1/domains")["domains"]
279
+
280
+ def add(self, name: str) -> dict[str, Any]:
281
+ """Paid plans; returns the DNS records to create."""
282
+ return self._c._request("POST", "/api/v1/domains", json={"name": name})["domain"]
283
+
284
+ def check(self, domain_id: str) -> dict[str, Any]:
285
+ return self._c._request("POST", f"/api/v1/domains/{domain_id}/check", idempotent=True)["domain"]
286
+
287
+ def remove(self, domain_id: str) -> dict[str, Any]:
288
+ return self._c._request("DELETE", f"/api/v1/domains/{domain_id}")
289
+
290
+
291
+ class Attachments:
292
+ def __init__(self, c: AgentLoadout):
293
+ self._c = c
294
+
295
+ def download(self, attachment_id: str) -> bytes:
296
+ return self._c._request("GET", f"/api/v1/attachments/{attachment_id}")
297
+
298
+ def text(self, attachment_id: str) -> dict[str, Any]:
299
+ """Extracted text of PDF, CSV, JSON, HTML and plain-text files."""
300
+ return self._c._request("GET", f"/api/v1/attachments/{attachment_id}/text")
301
+
302
+ def upload(self, inbox_id: str, filename: str, content: bytes, content_type: str = "application/octet-stream") -> dict[str, Any]:
303
+ return self._c._request("POST", f"/api/v1/inboxes/{inbox_id}/uploads", json={"filename": filename, "content_type": content_type, "content_base64": base64.b64encode(content).decode("ascii")})
304
+
305
+ def delete_upload(self, upload_id: str) -> dict[str, Any]:
306
+ return self._c._request("DELETE", f"/api/v1/uploads/{upload_id}")
307
+
308
+
309
+ class AsyncAgentLoadout(_Base):
310
+ """Asynchronous client with the same surface; resource groups are exposed as coroutine methods on `request`."""
311
+
312
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, max_retries: int = 2, timeout: float = 30.0, client: httpx.AsyncClient | None = None):
313
+ super().__init__(api_key, base_url, max_retries, timeout)
314
+ self._client = client or httpx.AsyncClient(timeout=timeout)
315
+
316
+ async def request(self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any = None, idempotent: bool | None = None, timeout: float | None = None) -> Any:
317
+ import asyncio
318
+
319
+ if idempotent is None:
320
+ idempotent = method in ("GET", "DELETE") or bool(isinstance(json, dict) and json.get("idempotency_key"))
321
+ attempt = 0
322
+ while True:
323
+ attempt += 1
324
+ try:
325
+ response = await self._client.request(method, self._base_url + path, params=self._query(params), json=json, headers=self._headers, timeout=timeout or self._timeout)
326
+ except httpx.HTTPError as error:
327
+ if idempotent and attempt <= self._max_retries:
328
+ await asyncio.sleep(_backoff(attempt))
329
+ continue
330
+ raise AgentLoadoutError(0, "network_error", str(error)) from error
331
+ if response.is_success:
332
+ if "application/json" in response.headers.get("content-type", ""):
333
+ return response.json()
334
+ return response.content
335
+ if (response.status_code == 429 or response.status_code >= 500) and idempotent and attempt <= self._max_retries:
336
+ retry_after = response.headers.get("retry-after")
337
+ await asyncio.sleep(float(retry_after) if retry_after and retry_after.isdigit() else _backoff(attempt))
338
+ continue
339
+ _raise(response)
340
+
341
+ async def wait_for_message(self, inbox_id: str, *, from_: str | None = None, subject_contains: str | None = None, since: str | None = None, timeout: int = 60) -> Optional[dict[str, Any]]:
342
+ deadline = time.time() + timeout
343
+ while True:
344
+ step = max(1, min(25, int(deadline - time.time()) + 1))
345
+ body = {k: v for k, v in {"from": from_, "subject_contains": subject_contains, "since": since, "timeout": step}.items() if v is not None}
346
+ res = await self.request("POST", f"/api/v1/inboxes/{inbox_id}/wait", json=body, idempotent=True, timeout=step + 30)
347
+ if res.get("matched") and res.get("message"):
348
+ return res["message"]
349
+ since = res["cursor"]
350
+ if time.time() >= deadline:
351
+ return None
352
+
353
+
354
+ def signup(owner_email: str, *, workspace: str | None = None, agent_name: str | None = None, username: str | None = None, base_url: str = DEFAULT_BASE_URL, client: httpx.Client | None = None) -> dict[str, Any]:
355
+ """Agent self-signup without a credential. Returns the pending workspace, agent, inbox and token; sending unlocks once the owner claims it."""
356
+ body = {k: v for k, v in {"owner_email": owner_email, "workspace": workspace, "agent_name": agent_name, "username": username}.items() if v is not None}
357
+ response = (client or httpx.Client(timeout=30.0)).post(base_url.rstrip("/") + "/api/v1/signup", json=body, headers={"user-agent": USER_AGENT})
358
+ if not response.is_success:
359
+ _raise(response)
360
+ return response.json()
@@ -0,0 +1,19 @@
1
+ import hmac
2
+ import hashlib
3
+ import time
4
+ from typing import Mapping, Optional
5
+
6
+
7
+ def verify_webhook_signature(secret: str, headers: Mapping[str, str], raw_body: bytes | str, tolerance_seconds: int = 300, now: Optional[float] = None) -> bool:
8
+ """Verify X-Loadout-Signature over the raw request body. Pass the headers as received (case-insensitive lookup)."""
9
+ lower = {k.lower(): v for k, v in headers.items()}
10
+ signature = lower.get("x-loadout-signature")
11
+ timestamp = lower.get("x-loadout-timestamp")
12
+ if not signature or not timestamp or not timestamp.isdigit() or len(timestamp) > 12:
13
+ return False
14
+ ts = int(timestamp)
15
+ if abs((now if now is not None else time.time()) - ts) > tolerance_seconds:
16
+ return False
17
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
18
+ expected = "v1=" + hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
19
+ return hmac.compare_digest(expected, signature.strip())
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "agentloadout"
3
+ version = "0.1.1"
4
+ description = "Python client for the Agent Loadout API: inboxes, messages, realtime events, screening, webhooks and credentials for AI agents."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = ["httpx>=0.27"]
10
+ keywords = ["agent-loadout", "email", "ai-agents", "inbox", "webhooks"]
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest>=8"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://agent-loadout.com"
17
+ Repository = "https://github.com/niklas-schmidt-dev/agentloadout"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["agentloadout"]
@@ -0,0 +1,67 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import time
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from agentloadout import AgentLoadout, AgentLoadoutError, verify_webhook_signature
10
+
11
+
12
+ def make_client(handler):
13
+ transport = httpx.MockTransport(handler)
14
+ return AgentLoadout(api_key="alk_test", base_url="https://api.test", client=httpx.Client(transport=transport))
15
+
16
+
17
+ def test_sends_bearer_and_parses_json():
18
+ def handler(request: httpx.Request):
19
+ assert request.headers["authorization"] == "Bearer alk_test"
20
+ assert request.url.path == "/api/v1/inboxes"
21
+ assert request.url.params["metadata.tenant"] == "acme"
22
+ return httpx.Response(200, json={"inboxes": [{"id": "inb_1"}]})
23
+
24
+ assert make_client(handler).inboxes.list(metadata={"tenant": "acme"}) == [{"id": "inb_1"}]
25
+
26
+
27
+ def test_retries_idempotent_requests_and_raises_typed_errors():
28
+ calls = {"n": 0}
29
+
30
+ def handler(request: httpx.Request):
31
+ calls["n"] += 1
32
+ if calls["n"] == 1:
33
+ return httpx.Response(503)
34
+ return httpx.Response(200, json={"message": {"id": "msg_1"}})
35
+
36
+ assert make_client(handler).messages.get("msg_1")["id"] == "msg_1"
37
+ assert calls["n"] == 2
38
+
39
+ def forbidden(request: httpx.Request):
40
+ return httpx.Response(403, json={"error": {"code": "forbidden", "message": "nope", "hint": "use a key"}}, headers={"x-request-id": "r1"})
41
+
42
+ with pytest.raises(AgentLoadoutError) as info:
43
+ make_client(forbidden).agents.list()
44
+ assert (info.value.status, info.value.code, info.value.hint, info.value.request_id) == (403, "forbidden", "use a key", "r1")
45
+
46
+
47
+ def test_send_is_not_retried_without_idempotency_key():
48
+ calls = {"n": 0}
49
+
50
+ def handler(request: httpx.Request):
51
+ calls["n"] += 1
52
+ return httpx.Response(500, json={"error": {"code": "internal", "message": "boom"}})
53
+
54
+ with pytest.raises(AgentLoadoutError):
55
+ make_client(handler).messages.send("inb_1", ["a@b.c"], "Hi", text="x")
56
+ assert calls["n"] == 1
57
+
58
+
59
+ def test_verify_webhook_signature():
60
+ secret, body = "whsec_test", json.dumps({"a": 1})
61
+ ts = str(int(time.time()))
62
+ sig = "v1=" + hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
63
+ headers = {"X-Loadout-Signature": sig, "X-Loadout-Timestamp": ts}
64
+ assert verify_webhook_signature(secret, headers, body)
65
+ assert not verify_webhook_signature("other", headers, body)
66
+ assert not verify_webhook_signature(secret, headers, body + " ")
67
+ assert not verify_webhook_signature(secret, headers, body, now=time.time() + 3600)