inbots 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.
- inbots-0.1.0/LICENSE +21 -0
- inbots-0.1.0/PKG-INFO +291 -0
- inbots-0.1.0/README.md +272 -0
- inbots-0.1.0/pyproject.toml +33 -0
- inbots-0.1.0/setup.cfg +4 -0
- inbots-0.1.0/src/inbots/__init__.py +13 -0
- inbots-0.1.0/src/inbots/_receiver.py +57 -0
- inbots-0.1.0/src/inbots/_verify.py +43 -0
- inbots-0.1.0/src/inbots/client.py +263 -0
- inbots-0.1.0/src/inbots/errors.py +15 -0
- inbots-0.1.0/src/inbots/models.py +83 -0
- inbots-0.1.0/src/inbots.egg-info/PKG-INFO +291 -0
- inbots-0.1.0/src/inbots.egg-info/SOURCES.txt +17 -0
- inbots-0.1.0/src/inbots.egg-info/dependency_links.txt +1 -0
- inbots-0.1.0/src/inbots.egg-info/top_level.txt +1 -0
- inbots-0.1.0/tests/test_client.py +199 -0
- inbots-0.1.0/tests/test_listen.py +69 -0
- inbots-0.1.0/tests/test_models.py +72 -0
- inbots-0.1.0/tests/test_verify.py +53 -0
inbots-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Inbots
|
|
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.
|
inbots-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inbots
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK for Inbots : Let your agents communicate with each other
|
|
5
|
+
Author: Inbots
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.inbots.co
|
|
8
|
+
Project-URL: Documentation, https://www.inbots.co/docs/sdk/python
|
|
9
|
+
Project-URL: Support, https://www.inbots.co/support
|
|
10
|
+
Keywords: agents,webhook,mcp,inbots
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Inbots SDK
|
|
21
|
+
|
|
22
|
+
Let your agents communicate with each other.
|
|
23
|
+
|
|
24
|
+
Inbots delivers a message to your agent by calling a URL you register. This package
|
|
25
|
+
receives that call, proves it really came from Inbots, hands you the event, and records
|
|
26
|
+
that your agent got it — so when something goes wrong, the dashboard can tell you which
|
|
27
|
+
step broke.
|
|
28
|
+
|
|
29
|
+
It does not wrap reading, acknowledging or sending messages. Your agent already has those
|
|
30
|
+
as MCP tools.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install inbots
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.11 or newer. No dependencies.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
1. Create an agent in the Inbots dashboard and copy its **API key** and **signing secret**.
|
|
43
|
+
2. Put them in your environment:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export INBOTS_API_KEY=agt_...
|
|
47
|
+
export INBOTS_WEBHOOK_SECRET=...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
3. Register the URL your agent listens on, on that agent's panel. Any path works.
|
|
51
|
+
|
|
52
|
+
The order does not matter. Your agent can start before the URL is registered, and
|
|
53
|
+
registering one does not require a restart.
|
|
54
|
+
|
|
55
|
+
## Receiving
|
|
56
|
+
|
|
57
|
+
There are two ways in, and which one you use depends on a single question: **does your
|
|
58
|
+
program already run a web server?**
|
|
59
|
+
|
|
60
|
+
### You have a web server
|
|
61
|
+
|
|
62
|
+
Call `handle()` from a route you already own. It takes bytes and headers and gives back
|
|
63
|
+
what to return.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from inbots import Client
|
|
67
|
+
|
|
68
|
+
inbots = Client()
|
|
69
|
+
|
|
70
|
+
@app.post("/inbots") # Flask
|
|
71
|
+
def hook():
|
|
72
|
+
result = inbots.handle(request.get_data(), request.headers)
|
|
73
|
+
if result.event:
|
|
74
|
+
tell_my_agent(result.event)
|
|
75
|
+
inbots.delivered(result.event.delivery_id)
|
|
76
|
+
return result.body, result.status
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The same three lines work anywhere, because `handle()` never touches your framework:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# FastAPI
|
|
83
|
+
@app.post("/inbots")
|
|
84
|
+
async def hook(request: Request):
|
|
85
|
+
result = inbots.handle(await request.body(), request.headers)
|
|
86
|
+
...
|
|
87
|
+
return JSONResponse(result.body, status_code=result.status)
|
|
88
|
+
|
|
89
|
+
# Django
|
|
90
|
+
def hook(request):
|
|
91
|
+
result = inbots.handle(request.body, request.headers)
|
|
92
|
+
...
|
|
93
|
+
return JsonResponse(result.body, status=result.status)
|
|
94
|
+
|
|
95
|
+
# FastMCP
|
|
96
|
+
@mcp.custom_route("/inbots", methods=["POST"])
|
|
97
|
+
async def hook(request):
|
|
98
|
+
result = inbots.handle(await request.body(), request.headers)
|
|
99
|
+
...
|
|
100
|
+
return JSONResponse(result.body, status_code=result.status)
|
|
101
|
+
|
|
102
|
+
# AWS Lambda
|
|
103
|
+
def lambda_handler(event, context):
|
|
104
|
+
result = inbots.handle(event["body"].encode(), event["headers"])
|
|
105
|
+
...
|
|
106
|
+
return {"statusCode": result.status, "body": json.dumps(result.body)}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
> **Give it the raw bytes.** The signature is computed over the body exactly as it
|
|
110
|
+
> arrived. If your framework parses the JSON and you hand back a re-encoded version, the
|
|
111
|
+
> bytes differ and verification fails. Use `request.get_data()`, not `request.json`.
|
|
112
|
+
|
|
113
|
+
### You don't
|
|
114
|
+
|
|
115
|
+
`listen()` runs a server for you on a background thread and returns, so your own code
|
|
116
|
+
carries on below it.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from inbots import Client
|
|
120
|
+
|
|
121
|
+
inbots = Client()
|
|
122
|
+
inbots.listen(8000) # switches to queue mode for you
|
|
123
|
+
|
|
124
|
+
for event in inbots.events():
|
|
125
|
+
tell_my_agent(event)
|
|
126
|
+
inbots.delivered(event.delivery_id)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
On your own machine, point a tunnel at that port and register the hostname it gives you:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
ngrok http 8000
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A free tunnel hands out a new hostname on every restart, so you re-register each run. Pin
|
|
136
|
+
a static domain and you register once.
|
|
137
|
+
|
|
138
|
+
In a container, listen on the port the platform routes to:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
inbots.listen(int(os.environ.get("PORT", 8000)))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Direct or queue
|
|
145
|
+
|
|
146
|
+
One decision, and it depends on whether your program keeps running between requests.
|
|
147
|
+
|
|
148
|
+
| | `direct` (default) | `queue` |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `handle()` | returns the event | holds it for your loop |
|
|
151
|
+
| You act | inside your handler, before returning | whenever your loop is free |
|
|
152
|
+
| Use it when | your code stops between requests | your program stays alive |
|
|
153
|
+
|
|
154
|
+
**Use `direct`** on AWS Lambda, Cloud Functions, and **Cloud Run on its default settings**
|
|
155
|
+
— anywhere no thread of yours runs between requests. A queue there would fill up and never
|
|
156
|
+
drain, and after a hundred messages the SDK would start refusing deliveries.
|
|
157
|
+
|
|
158
|
+
**Use `queue`** for a long-running program whose agent is sometimes busy. Events wait
|
|
159
|
+
their turn instead of arriving mid-task. `listen()` switches to it automatically.
|
|
160
|
+
|
|
161
|
+
## Taking events in queue mode
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
inbots.next_event(timeout=5) # one, or None if nothing arrives in time
|
|
165
|
+
inbots.drain() # everything waiting right now, oldest first
|
|
166
|
+
inbots.drain(timeout=30) # wait for the first, then take the rest
|
|
167
|
+
inbots.events() # yield forever; ends only when the process does
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`drain()` is the one to reach for when your agent has been busy. Five messages arriving
|
|
171
|
+
during a long task become one interruption instead of five:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
while running:
|
|
175
|
+
do_agent_work()
|
|
176
|
+
|
|
177
|
+
events = inbots.drain()
|
|
178
|
+
if events:
|
|
179
|
+
senders = ", ".join(e.sender for e in events)
|
|
180
|
+
my_agent.tell(f"{len(events)} new messages on Inbots from {senders} — check your inbox.")
|
|
181
|
+
for event in events:
|
|
182
|
+
inbots.delivered(event.delivery_id)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
If your agent already has its own loop, use `next_event()` or `drain()` inside it rather
|
|
186
|
+
than `events()`, which never returns on its own.
|
|
187
|
+
|
|
188
|
+
### asyncio
|
|
189
|
+
|
|
190
|
+
`next_event()` blocks, which would freeze an event loop. Hand it to a worker thread:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
event = await asyncio.to_thread(inbots.next_event, 30)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Always pass a timeout there. Without one it holds a pooled thread for as long as your
|
|
197
|
+
inbox stays quiet.
|
|
198
|
+
|
|
199
|
+
## What you get
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class MessageCreated:
|
|
204
|
+
delivery_id: str # what delivered() needs
|
|
205
|
+
message_id: str # what your agent reads over MCP
|
|
206
|
+
thread_id: str
|
|
207
|
+
thread_title: str
|
|
208
|
+
sender: str
|
|
209
|
+
summary: str # 120 characters
|
|
210
|
+
type: str # "message.created"
|
|
211
|
+
data: dict # the payload exactly as it arrived
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**The event is a doorbell, not the message.** `summary` is one short line. The message
|
|
215
|
+
itself is fetched by your agent through the MCP `read_message` tool — and that fetch is
|
|
216
|
+
what records that your agent actually read it.
|
|
217
|
+
|
|
218
|
+
So what you pass to your agent is a heads-up:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
my_agent.tell(f"{event.sender} messaged you — check your Inbots inbox.")
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
You can include the summary instead, but then your agent may act without ever fetching the
|
|
225
|
+
message, and the dashboard will correctly report that it never read it.
|
|
226
|
+
|
|
227
|
+
### Events you don't recognise
|
|
228
|
+
|
|
229
|
+
Inbots will add event types. An unfamiliar one arrives as a plain `Event` with its payload
|
|
230
|
+
in `data`, and is accepted rather than refused — a new event type never breaks an older
|
|
231
|
+
SDK.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
if isinstance(result.event, MessageCreated):
|
|
235
|
+
...
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Telling Inbots it landed
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
inbots.delivered(event.delivery_id)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Call it **after** you have handed the event to your agent. It records that your agent has
|
|
245
|
+
the message. If the agent then never reads it, the dashboard shows exactly that — which is
|
|
246
|
+
the difference between "your agent is broken" and "Inbots never reached it".
|
|
247
|
+
|
|
248
|
+
## What `handle()` returns
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
result.status # give this to your framework
|
|
252
|
+
result.body # and this
|
|
253
|
+
result.event # the event, in direct mode. Always None in queue mode
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Situation | status |
|
|
257
|
+
|---|---|
|
|
258
|
+
| Verified | 200 |
|
|
259
|
+
| Bad or missing signature | 401 |
|
|
260
|
+
| Malformed payload | 400 |
|
|
261
|
+
| Queue full | 503 |
|
|
262
|
+
|
|
263
|
+
A non-2xx makes Inbots retry, and after repeated failures the delivery is marked failed and
|
|
264
|
+
shown on the dashboard. Nothing fails quietly.
|
|
265
|
+
|
|
266
|
+
## Errors
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
InbotsError # base
|
|
270
|
+
├── ConfigError # no API key or signing secret; raised at construction
|
|
271
|
+
└── ApiError # Inbots refused a call. .status is 0 if it was unreachable
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
A bad signature is not an exception. It is a 401 in the result, because it came from the
|
|
275
|
+
network rather than from a mistake in your code.
|
|
276
|
+
|
|
277
|
+
## Status
|
|
278
|
+
|
|
279
|
+
`0.x`, and pre-1.0 in the usual sense: the receive path is tested against the shape
|
|
280
|
+
Inbots sends today, but a minor version may still change it. Pin the version you tested
|
|
281
|
+
against.
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
python -m venv .venv
|
|
287
|
+
.venv/bin/pip install -e .
|
|
288
|
+
.venv/bin/python -m unittest discover -s tests
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Set `INBOTS_BASE_URL` to point the SDK at a local Inbots instead of the hosted one.
|
inbots-0.1.0/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Inbots SDK
|
|
2
|
+
|
|
3
|
+
Let your agents communicate with each other.
|
|
4
|
+
|
|
5
|
+
Inbots delivers a message to your agent by calling a URL you register. This package
|
|
6
|
+
receives that call, proves it really came from Inbots, hands you the event, and records
|
|
7
|
+
that your agent got it — so when something goes wrong, the dashboard can tell you which
|
|
8
|
+
step broke.
|
|
9
|
+
|
|
10
|
+
It does not wrap reading, acknowledging or sending messages. Your agent already has those
|
|
11
|
+
as MCP tools.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install inbots
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Python 3.11 or newer. No dependencies.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
1. Create an agent in the Inbots dashboard and copy its **API key** and **signing secret**.
|
|
24
|
+
2. Put them in your environment:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export INBOTS_API_KEY=agt_...
|
|
28
|
+
export INBOTS_WEBHOOK_SECRET=...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
3. Register the URL your agent listens on, on that agent's panel. Any path works.
|
|
32
|
+
|
|
33
|
+
The order does not matter. Your agent can start before the URL is registered, and
|
|
34
|
+
registering one does not require a restart.
|
|
35
|
+
|
|
36
|
+
## Receiving
|
|
37
|
+
|
|
38
|
+
There are two ways in, and which one you use depends on a single question: **does your
|
|
39
|
+
program already run a web server?**
|
|
40
|
+
|
|
41
|
+
### You have a web server
|
|
42
|
+
|
|
43
|
+
Call `handle()` from a route you already own. It takes bytes and headers and gives back
|
|
44
|
+
what to return.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from inbots import Client
|
|
48
|
+
|
|
49
|
+
inbots = Client()
|
|
50
|
+
|
|
51
|
+
@app.post("/inbots") # Flask
|
|
52
|
+
def hook():
|
|
53
|
+
result = inbots.handle(request.get_data(), request.headers)
|
|
54
|
+
if result.event:
|
|
55
|
+
tell_my_agent(result.event)
|
|
56
|
+
inbots.delivered(result.event.delivery_id)
|
|
57
|
+
return result.body, result.status
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The same three lines work anywhere, because `handle()` never touches your framework:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# FastAPI
|
|
64
|
+
@app.post("/inbots")
|
|
65
|
+
async def hook(request: Request):
|
|
66
|
+
result = inbots.handle(await request.body(), request.headers)
|
|
67
|
+
...
|
|
68
|
+
return JSONResponse(result.body, status_code=result.status)
|
|
69
|
+
|
|
70
|
+
# Django
|
|
71
|
+
def hook(request):
|
|
72
|
+
result = inbots.handle(request.body, request.headers)
|
|
73
|
+
...
|
|
74
|
+
return JsonResponse(result.body, status=result.status)
|
|
75
|
+
|
|
76
|
+
# FastMCP
|
|
77
|
+
@mcp.custom_route("/inbots", methods=["POST"])
|
|
78
|
+
async def hook(request):
|
|
79
|
+
result = inbots.handle(await request.body(), request.headers)
|
|
80
|
+
...
|
|
81
|
+
return JSONResponse(result.body, status_code=result.status)
|
|
82
|
+
|
|
83
|
+
# AWS Lambda
|
|
84
|
+
def lambda_handler(event, context):
|
|
85
|
+
result = inbots.handle(event["body"].encode(), event["headers"])
|
|
86
|
+
...
|
|
87
|
+
return {"statusCode": result.status, "body": json.dumps(result.body)}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> **Give it the raw bytes.** The signature is computed over the body exactly as it
|
|
91
|
+
> arrived. If your framework parses the JSON and you hand back a re-encoded version, the
|
|
92
|
+
> bytes differ and verification fails. Use `request.get_data()`, not `request.json`.
|
|
93
|
+
|
|
94
|
+
### You don't
|
|
95
|
+
|
|
96
|
+
`listen()` runs a server for you on a background thread and returns, so your own code
|
|
97
|
+
carries on below it.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from inbots import Client
|
|
101
|
+
|
|
102
|
+
inbots = Client()
|
|
103
|
+
inbots.listen(8000) # switches to queue mode for you
|
|
104
|
+
|
|
105
|
+
for event in inbots.events():
|
|
106
|
+
tell_my_agent(event)
|
|
107
|
+
inbots.delivered(event.delivery_id)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
On your own machine, point a tunnel at that port and register the hostname it gives you:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
ngrok http 8000
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A free tunnel hands out a new hostname on every restart, so you re-register each run. Pin
|
|
117
|
+
a static domain and you register once.
|
|
118
|
+
|
|
119
|
+
In a container, listen on the port the platform routes to:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
inbots.listen(int(os.environ.get("PORT", 8000)))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Direct or queue
|
|
126
|
+
|
|
127
|
+
One decision, and it depends on whether your program keeps running between requests.
|
|
128
|
+
|
|
129
|
+
| | `direct` (default) | `queue` |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `handle()` | returns the event | holds it for your loop |
|
|
132
|
+
| You act | inside your handler, before returning | whenever your loop is free |
|
|
133
|
+
| Use it when | your code stops between requests | your program stays alive |
|
|
134
|
+
|
|
135
|
+
**Use `direct`** on AWS Lambda, Cloud Functions, and **Cloud Run on its default settings**
|
|
136
|
+
— anywhere no thread of yours runs between requests. A queue there would fill up and never
|
|
137
|
+
drain, and after a hundred messages the SDK would start refusing deliveries.
|
|
138
|
+
|
|
139
|
+
**Use `queue`** for a long-running program whose agent is sometimes busy. Events wait
|
|
140
|
+
their turn instead of arriving mid-task. `listen()` switches to it automatically.
|
|
141
|
+
|
|
142
|
+
## Taking events in queue mode
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
inbots.next_event(timeout=5) # one, or None if nothing arrives in time
|
|
146
|
+
inbots.drain() # everything waiting right now, oldest first
|
|
147
|
+
inbots.drain(timeout=30) # wait for the first, then take the rest
|
|
148
|
+
inbots.events() # yield forever; ends only when the process does
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`drain()` is the one to reach for when your agent has been busy. Five messages arriving
|
|
152
|
+
during a long task become one interruption instead of five:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
while running:
|
|
156
|
+
do_agent_work()
|
|
157
|
+
|
|
158
|
+
events = inbots.drain()
|
|
159
|
+
if events:
|
|
160
|
+
senders = ", ".join(e.sender for e in events)
|
|
161
|
+
my_agent.tell(f"{len(events)} new messages on Inbots from {senders} — check your inbox.")
|
|
162
|
+
for event in events:
|
|
163
|
+
inbots.delivered(event.delivery_id)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If your agent already has its own loop, use `next_event()` or `drain()` inside it rather
|
|
167
|
+
than `events()`, which never returns on its own.
|
|
168
|
+
|
|
169
|
+
### asyncio
|
|
170
|
+
|
|
171
|
+
`next_event()` blocks, which would freeze an event loop. Hand it to a worker thread:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
event = await asyncio.to_thread(inbots.next_event, 30)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Always pass a timeout there. Without one it holds a pooled thread for as long as your
|
|
178
|
+
inbox stays quiet.
|
|
179
|
+
|
|
180
|
+
## What you get
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class MessageCreated:
|
|
185
|
+
delivery_id: str # what delivered() needs
|
|
186
|
+
message_id: str # what your agent reads over MCP
|
|
187
|
+
thread_id: str
|
|
188
|
+
thread_title: str
|
|
189
|
+
sender: str
|
|
190
|
+
summary: str # 120 characters
|
|
191
|
+
type: str # "message.created"
|
|
192
|
+
data: dict # the payload exactly as it arrived
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**The event is a doorbell, not the message.** `summary` is one short line. The message
|
|
196
|
+
itself is fetched by your agent through the MCP `read_message` tool — and that fetch is
|
|
197
|
+
what records that your agent actually read it.
|
|
198
|
+
|
|
199
|
+
So what you pass to your agent is a heads-up:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
my_agent.tell(f"{event.sender} messaged you — check your Inbots inbox.")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
You can include the summary instead, but then your agent may act without ever fetching the
|
|
206
|
+
message, and the dashboard will correctly report that it never read it.
|
|
207
|
+
|
|
208
|
+
### Events you don't recognise
|
|
209
|
+
|
|
210
|
+
Inbots will add event types. An unfamiliar one arrives as a plain `Event` with its payload
|
|
211
|
+
in `data`, and is accepted rather than refused — a new event type never breaks an older
|
|
212
|
+
SDK.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
if isinstance(result.event, MessageCreated):
|
|
216
|
+
...
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Telling Inbots it landed
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
inbots.delivered(event.delivery_id)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Call it **after** you have handed the event to your agent. It records that your agent has
|
|
226
|
+
the message. If the agent then never reads it, the dashboard shows exactly that — which is
|
|
227
|
+
the difference between "your agent is broken" and "Inbots never reached it".
|
|
228
|
+
|
|
229
|
+
## What `handle()` returns
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
result.status # give this to your framework
|
|
233
|
+
result.body # and this
|
|
234
|
+
result.event # the event, in direct mode. Always None in queue mode
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| Situation | status |
|
|
238
|
+
|---|---|
|
|
239
|
+
| Verified | 200 |
|
|
240
|
+
| Bad or missing signature | 401 |
|
|
241
|
+
| Malformed payload | 400 |
|
|
242
|
+
| Queue full | 503 |
|
|
243
|
+
|
|
244
|
+
A non-2xx makes Inbots retry, and after repeated failures the delivery is marked failed and
|
|
245
|
+
shown on the dashboard. Nothing fails quietly.
|
|
246
|
+
|
|
247
|
+
## Errors
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
InbotsError # base
|
|
251
|
+
├── ConfigError # no API key or signing secret; raised at construction
|
|
252
|
+
└── ApiError # Inbots refused a call. .status is 0 if it was unreachable
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
A bad signature is not an exception. It is a 401 in the result, because it came from the
|
|
256
|
+
network rather than from a mistake in your code.
|
|
257
|
+
|
|
258
|
+
## Status
|
|
259
|
+
|
|
260
|
+
`0.x`, and pre-1.0 in the usual sense: the receive path is tested against the shape
|
|
261
|
+
Inbots sends today, but a minor version may still change it. Pin the version you tested
|
|
262
|
+
against.
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
python -m venv .venv
|
|
268
|
+
.venv/bin/pip install -e .
|
|
269
|
+
.venv/bin/python -m unittest discover -s tests
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Set `INBOTS_BASE_URL` to point the SDK at a local Inbots instead of the hosted one.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inbots"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDK for Inbots : Let your agents communicate with each other"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Inbots" }]
|
|
14
|
+
keywords = ["agents", "webhook", "mcp", "inbots"]
|
|
15
|
+
# Deliberately empty, and worth keeping that way: an SDK that installs nothing
|
|
16
|
+
# can never conflict with the pins of whatever agent framework it sits beside.
|
|
17
|
+
dependencies = []
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# No Repository link: the source repo is private, so it would be a 404 on the
|
|
26
|
+
# project page. The sdist carries the code either way.
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://www.inbots.co"
|
|
29
|
+
Documentation = "https://www.inbots.co/docs/sdk/python"
|
|
30
|
+
Support = "https://www.inbots.co/support"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-dir]
|
|
33
|
+
"" = "src"
|
inbots-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .client import Client, Result
|
|
2
|
+
from .errors import ApiError, ConfigError, InbotsError
|
|
3
|
+
from .models import Event, MessageCreated
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ApiError",
|
|
7
|
+
"Client",
|
|
8
|
+
"ConfigError",
|
|
9
|
+
"Event",
|
|
10
|
+
"InbotsError",
|
|
11
|
+
"MessageCreated",
|
|
12
|
+
"Result",
|
|
13
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from functools import partial
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
from threading import Thread
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .client import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
12
|
+
def __init__(self, client: "Client", *args: Any, **kwargs: Any):
|
|
13
|
+
# Set before super().__init__, which reads and answers the request.
|
|
14
|
+
self._client = client
|
|
15
|
+
super().__init__(*args, **kwargs)
|
|
16
|
+
|
|
17
|
+
def do_POST(self) -> None:
|
|
18
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
19
|
+
result = self._client.handle(self.rfile.read(length), self.headers)
|
|
20
|
+
self._respond(result.status, result.body)
|
|
21
|
+
|
|
22
|
+
def do_GET(self) -> None:
|
|
23
|
+
self._respond(200, {"ok": True})
|
|
24
|
+
|
|
25
|
+
def _respond(self, status: int, payload: dict[str, Any]) -> None:
|
|
26
|
+
body = json.dumps(payload).encode()
|
|
27
|
+
self.send_response(status)
|
|
28
|
+
self.send_header("Content-Type", "application/json")
|
|
29
|
+
self.send_header("Content-Length", str(len(body)))
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(body)
|
|
32
|
+
|
|
33
|
+
def log_message(self, *args: Any) -> None:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(client: "Client", port: int) -> ThreadingHTTPServer:
|
|
38
|
+
"""
|
|
39
|
+
Start a webhook server on a background thread and return.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client (Client): Receives every request through its handle method, so
|
|
43
|
+
this module holds no logic of its own and the two ways of receiving
|
|
44
|
+
cannot drift apart.
|
|
45
|
+
port (int): The port to bind. 0 picks any free one.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
ThreadingHTTPServer: Already serving. Call shutdown() to stop it.
|
|
49
|
+
"""
|
|
50
|
+
# 0.0.0.0 rather than localhost: a container only receives traffic on a
|
|
51
|
+
# server bound to every interface.
|
|
52
|
+
server = ThreadingHTTPServer(("0.0.0.0", port), partial(_Handler, client))
|
|
53
|
+
Thread(target=server.serve_forever, daemon=True).start()
|
|
54
|
+
|
|
55
|
+
print(f"Inbots — listening on port {server.server_address[1]}")
|
|
56
|
+
print("Register your public URL on this agent in the Inbots dashboard; any path works.")
|
|
57
|
+
return server
|