agentloadout 0.1.1__py3-none-any.whl
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.
agentloadout/__init__.py
ADDED
|
@@ -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"
|
agentloadout/client.py
ADDED
|
@@ -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()
|
agentloadout/webhooks.py
ADDED
|
@@ -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,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,7 @@
|
|
|
1
|
+
agentloadout/__init__.py,sha256=D09SWySkhWRJPSqc-btAYqJb8xyo5XQEhTN9U3Nzayw,355
|
|
2
|
+
agentloadout/client.py,sha256=O8eVwLvBkDVbYmrrTqMcZ6scLmjxKKEEA8UrR1l_HaE,20041
|
|
3
|
+
agentloadout/webhooks.py,sha256=rXEXzZA_J6VD6W2aLz9trNp7j3i4T7akwLzXRzPUc_U,1003
|
|
4
|
+
agentloadout-0.1.1.dist-info/METADATA,sha256=YoN9PwvRwYeKI2LXzMlDpXBmW3Fy6yC1ENXlulxnkVI,1662
|
|
5
|
+
agentloadout-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
agentloadout-0.1.1.dist-info/licenses/LICENSE,sha256=5qj7EON7tzU1YMtM7HF2GYAd19NbbhKrV2ZS9ktnVi0,1071
|
|
7
|
+
agentloadout-0.1.1.dist-info/RECORD,,
|
|
@@ -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.
|