deliverd 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.
- deliverd-0.1.0/.gitignore +35 -0
- deliverd-0.1.0/LICENSE +21 -0
- deliverd-0.1.0/PKG-INFO +342 -0
- deliverd-0.1.0/README.md +297 -0
- deliverd-0.1.0/pyproject.toml +47 -0
- deliverd-0.1.0/src/deliverd/__init__.py +411 -0
- deliverd-0.1.0/src/deliverd/_http.py +265 -0
- deliverd-0.1.0/src/deliverd/_wire.py +157 -0
- deliverd-0.1.0/src/deliverd/approvals.py +368 -0
- deliverd-0.1.0/src/deliverd/collections.py +245 -0
- deliverd-0.1.0/src/deliverd/dev.py +446 -0
- deliverd-0.1.0/src/deliverd/duration.py +49 -0
- deliverd-0.1.0/src/deliverd/errors.py +75 -0
- deliverd-0.1.0/src/deliverd/messages.py +123 -0
- deliverd-0.1.0/src/deliverd/paging.py +71 -0
- deliverd-0.1.0/src/deliverd/py.typed +0 -0
- deliverd-0.1.0/src/deliverd/reports.py +142 -0
- deliverd-0.1.0/src/deliverd/reviews.py +240 -0
- deliverd-0.1.0/src/deliverd/types.py +406 -0
- deliverd-0.1.0/src/deliverd/webhooks.py +124 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# next.js
|
|
6
|
+
apps/web/.next/
|
|
7
|
+
apps/web/out/
|
|
8
|
+
apps/web/next-env.d.ts
|
|
9
|
+
|
|
10
|
+
# builds
|
|
11
|
+
dist/
|
|
12
|
+
*.tsbuildinfo
|
|
13
|
+
|
|
14
|
+
# env
|
|
15
|
+
.env
|
|
16
|
+
.env.local
|
|
17
|
+
.env*.local
|
|
18
|
+
|
|
19
|
+
# supabase
|
|
20
|
+
supabase/.branches
|
|
21
|
+
supabase/.temp
|
|
22
|
+
|
|
23
|
+
# testing
|
|
24
|
+
coverage/
|
|
25
|
+
|
|
26
|
+
# misc
|
|
27
|
+
.DS_Store
|
|
28
|
+
*.pem
|
|
29
|
+
.vercel
|
|
30
|
+
|
|
31
|
+
# Python (packages/sdk-python)
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.py[cod]
|
|
34
|
+
.pytest_cache/
|
|
35
|
+
*.egg-info/
|
deliverd-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deliverd
|
|
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.
|
deliverd-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deliverd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add human approval to any AI agent. Ask a person, wait for the answer, act on it.
|
|
5
|
+
Project-URL: Homepage, https://deliverd.dev/developers
|
|
6
|
+
Project-URL: Documentation, https://deliverd.dev/developers
|
|
7
|
+
Project-URL: Source, https://github.com/davidpreid/deliverd
|
|
8
|
+
Project-URL: Issues, https://github.com/davidpreid/deliverd/issues
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Deliverd
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: agent,ai,approval,deliverd,human-in-the-loop,mcp
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Classifier: Typing :: Typed
|
|
43
|
+
Requires-Python: >=3.9
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# deliverd
|
|
47
|
+
|
|
48
|
+
**Add human approval to any AI agent.** Ask a person, wait for the answer, act
|
|
49
|
+
on it.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install deliverd
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from deliverd import deliverd
|
|
57
|
+
|
|
58
|
+
decision = deliverd.approve(title="Refund £1,240 to Acme", approvers=["Finance"])
|
|
59
|
+
if decision.approved:
|
|
60
|
+
refund()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That is the whole integration. The call blocks until somebody decides, and
|
|
64
|
+
returns whether they said yes.
|
|
65
|
+
|
|
66
|
+
What it stands in for: an approval page, the emails and the reminders, identity
|
|
67
|
+
and single sign-on, who is allowed to decide, a table to keep it in, an audit
|
|
68
|
+
trail, and the dashboard somebody uses to do it. None of that is yours to
|
|
69
|
+
build.
|
|
70
|
+
|
|
71
|
+
- **No dependencies.** The standard library has HTTP, HMAC and JSON. Nothing
|
|
72
|
+
new lands in your agent's environment.
|
|
73
|
+
- **Python 3.9 and up.**
|
|
74
|
+
- **Calls block.** The function that asks is the function that answers, which
|
|
75
|
+
is the shape an agent tool wants. There is no async client yet — see
|
|
76
|
+
[Where this is going](#where-this-is-going).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Before you have an account
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from deliverd import Deliverd
|
|
84
|
+
|
|
85
|
+
deliverd = Deliverd(mode="development")
|
|
86
|
+
decision = deliverd.approve(title="Deploy to production")
|
|
87
|
+
print(decision.approved) # True
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
No key, no network, no colleague to interrupt. The imaginary approver decides
|
|
91
|
+
instantly, and the four things that can happen to a real request are all one
|
|
92
|
+
line away:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
Deliverd(mode="development", development=DevelopmentOptions(outcome="rejected"))
|
|
96
|
+
Deliverd(mode="development", development=DevelopmentOptions(outcome="timeout"))
|
|
97
|
+
Deliverd(mode="development", development=DevelopmentOptions(outcome="question"))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
or `DELIVERD_DEV_OUTCOME=question` in the environment, so a whole test suite
|
|
101
|
+
flips without touching the code.
|
|
102
|
+
|
|
103
|
+
**`question` is the one people forget to write.** An approver can ask the agent
|
|
104
|
+
something before deciding, and until it is answered nothing moves:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
decision = deliverd.approve(
|
|
108
|
+
title="Refund £1,240 to Acme",
|
|
109
|
+
on_question=lambda q, approval: "Yes — staged on Tuesday",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Return `None` from the handler to leave it for a person.
|
|
114
|
+
|
|
115
|
+
Development mode is a stand-in transport, not a branch inside the client, so
|
|
116
|
+
the retries, the error handling and the polling are the same code that runs in
|
|
117
|
+
production. It answers the calls the primitives make and returns 501 for
|
|
118
|
+
everything else, rather than pretending to be the whole API.
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from deliverd import Deliverd
|
|
124
|
+
|
|
125
|
+
deliverd = Deliverd() # DELIVERD_API_KEY from the environment
|
|
126
|
+
deliverd = Deliverd(api_key="dlv_…") # or pass it
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Variable | What |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `DELIVERD_API_KEY` | A `dlv_` key from Settings → API tokens, or a `dlvo_` OAuth access token |
|
|
132
|
+
| `DELIVERD_BASE_URL` | Override for a self-hosted deployment |
|
|
133
|
+
| `DELIVERD_MODE` | `development` to run the loop offline |
|
|
134
|
+
| `DELIVERD_DEV_OUTCOME` | `approved` · `rejected` · `timeout` · `question` |
|
|
135
|
+
|
|
136
|
+
The module-level `deliverd` builds itself from the environment the first time
|
|
137
|
+
you touch it, so the shortest form has no setup line at all. Build a `Deliverd`
|
|
138
|
+
yourself when you need two keys, a different base URL, or development mode.
|
|
139
|
+
|
|
140
|
+
## The four verbs
|
|
141
|
+
|
|
142
|
+
| | You want | Call |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| **Approval** | May I do this? | `approve()` |
|
|
145
|
+
| **Confirmation** | Are you sure? — lower ceremony | `confirm()` |
|
|
146
|
+
| **Review** | Is what I made right? | `review()` |
|
|
147
|
+
| **Information** | A figure, a date, a choice | `collect()` |
|
|
148
|
+
| **Publishing** | People need to read this | `publish()` |
|
|
149
|
+
|
|
150
|
+
### Approve
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
decision = deliverd.approve(
|
|
154
|
+
title="Refund £1,240 to Acme",
|
|
155
|
+
description="Duplicate charge on invoice 4821.",
|
|
156
|
+
risk="high",
|
|
157
|
+
factors=[{"label": "Customer charged twice", "status": "warning"}],
|
|
158
|
+
links=[{"label": "Invoice 4821", "url": "https://…"}],
|
|
159
|
+
approvers=["Finance"], # a person, a team, a group — or omit it
|
|
160
|
+
expires_in="4h",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
decision.approved # True only for an approval
|
|
164
|
+
decision.status # approved | rejected | expired | cancelled
|
|
165
|
+
decision.note # their reason, when they gave one
|
|
166
|
+
decision.decided_by # "Sarah Chen" — a name, not an id
|
|
167
|
+
decision.decided_by_id # their user id, when the key is what you need
|
|
168
|
+
decision.url # the page they decided on
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**A refusal is an answer, not an error.** `approve()` returns on rejected,
|
|
172
|
+
expired and cancelled as well as approved; it raises only when the *wait*
|
|
173
|
+
failed — a timeout, a cancellation, or an API it could not reach.
|
|
174
|
+
|
|
175
|
+
`approvers` takes a person (email or user id), the name of a team or directory
|
|
176
|
+
group, or a word meaning the whole organisation. Naming a team is usually what
|
|
177
|
+
you want: a request addressed to one person waits for that person to come back
|
|
178
|
+
from holiday.
|
|
179
|
+
|
|
180
|
+
### Review
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
outcome = deliverd.review(
|
|
184
|
+
title="Q3 board pack",
|
|
185
|
+
report_id=report.id,
|
|
186
|
+
instructions="Check the figures against the ledger.",
|
|
187
|
+
reviewers=["partner@firm.example"],
|
|
188
|
+
)
|
|
189
|
+
if not outcome.approved:
|
|
190
|
+
revise(outcome.verdicts, outcome.thread_count)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Collect
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
answers = deliverd.collect(
|
|
197
|
+
title="Before I file the Q3 return",
|
|
198
|
+
respondents=["finance@firm.example"],
|
|
199
|
+
questions=[
|
|
200
|
+
{"prompt": "Headcount at 30 September", "kind": "number"},
|
|
201
|
+
{"prompt": "Any disposals in the quarter?", "kind": "boolean"},
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
if answers.complete:
|
|
205
|
+
file_return(answers["Headcount at 30 September"])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`answers` is keyed by the question as you asked it. Check `complete` before you
|
|
209
|
+
use it: a request that expired has whatever arrived and no more.
|
|
210
|
+
|
|
211
|
+
### Publish
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
result = deliverd.publish(title="Weekly figures", content=html, audience=["Acme"])
|
|
215
|
+
if result.held:
|
|
216
|
+
print("Waiting on a person before anyone can open it")
|
|
217
|
+
else:
|
|
218
|
+
print(result.url)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Restarting safely
|
|
222
|
+
|
|
223
|
+
Give a request your own `external_id` and a restarted agent finds the request it
|
|
224
|
+
already filed rather than asking two people about one act:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
deliverd.approve(title="Refund £1,240", external_id=f"refund-{invoice.id}")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
That is best effort — two *concurrent* calls can still both find nothing. For
|
|
231
|
+
the guarantee, pass `idempotency_key`, which the server enforces.
|
|
232
|
+
|
|
233
|
+
## Waiting
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
approval = deliverd.approvals.create(title="Deploy production")
|
|
237
|
+
print("Waiting at", approval.url)
|
|
238
|
+
approval.wait(on_poll=lambda a: print(".", end=""), timeout="30m")
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Polling backs off — a quick first look so a fast decision reads as one, then
|
|
242
|
+
somebody's afternoon rather than a progress bar. Pass a `threading.Event` as
|
|
243
|
+
`cancel` to stop a wait from another thread.
|
|
244
|
+
|
|
245
|
+
Every request expires, so a wait always ends: 24 hours unless you pass
|
|
246
|
+
`expires_in`.
|
|
247
|
+
|
|
248
|
+
> **Durations are seconds here, not milliseconds.** `timeout=30` is half a
|
|
249
|
+
> minute, because `time.sleep` takes seconds and so does everything else in
|
|
250
|
+
> Python. The string forms — `"30m"`, `"4h"`, `"2d"` — and `timedelta` mean the
|
|
251
|
+
> same thing in every Deliverd SDK. (The TypeScript package reads a bare number
|
|
252
|
+
> as milliseconds, for the mirror-image reason.)
|
|
253
|
+
|
|
254
|
+
## Errors
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from deliverd import DeliverdError, DeliverdTimeoutError
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
deliverd.approve(title="…")
|
|
261
|
+
except DeliverdTimeoutError:
|
|
262
|
+
... # you stopped waiting; the request is still open
|
|
263
|
+
except DeliverdError as err:
|
|
264
|
+
err.code # the API's own code: self_approval, rate_limited…
|
|
265
|
+
err.is_auth # 401 or 403
|
|
266
|
+
err.is_rate_limit # 429
|
|
267
|
+
err.is_not_found # 404
|
|
268
|
+
err.is_conflict # 409 — re-read and try again
|
|
269
|
+
err.hint # the one thing to do next
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Messages say what happened and what to do about it, rather than handing you a
|
|
273
|
+
code to search for.
|
|
274
|
+
|
|
275
|
+
## Webhooks
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from deliverd import construct_event, SIGNATURE_HEADER
|
|
279
|
+
|
|
280
|
+
@app.post("/webhooks/deliverd")
|
|
281
|
+
def receive():
|
|
282
|
+
event = construct_event(
|
|
283
|
+
secret=os.environ["DELIVERD_WEBHOOK_SECRET"],
|
|
284
|
+
body=request.get_data(as_text=True), # the RAW body
|
|
285
|
+
header=request.headers.get(SIGNATURE_HEADER),
|
|
286
|
+
)
|
|
287
|
+
if event.event == "approval.approved":
|
|
288
|
+
...
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`body` must be the raw request body. A re-encoded object is a different string
|
|
292
|
+
and will never verify — in FastAPI that is `(await request.body()).decode()`, in
|
|
293
|
+
Django `request.body.decode()`.
|
|
294
|
+
|
|
295
|
+
`construct_event` raises rather than returning `None`, because a route that
|
|
296
|
+
treats an unverifiable body as "no event" answers 200 and tells whoever is
|
|
297
|
+
posting that everything is fine.
|
|
298
|
+
|
|
299
|
+
## Lists
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
deliverd.approvals.list() # every one, following the cursor
|
|
303
|
+
deliverd.approvals.list(limit=20) # one page of twenty
|
|
304
|
+
page = deliverd.approvals.list_page(limit=20)
|
|
305
|
+
page.items, page.next_cursor
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**A `limit` means one page. No `limit` means all of them.** A full page and a
|
|
309
|
+
complete list are otherwise indistinguishable until somebody counts.
|
|
310
|
+
|
|
311
|
+
## Publishing (maintainers)
|
|
312
|
+
|
|
313
|
+
Tag and push; GitHub Actions builds, re-runs the suite on Python 3.9, and uploads
|
|
314
|
+
through PyPI Trusted Publishing — there is no API token anywhere.
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
git tag python-v0.1.0 && git push origin python-v0.1.0
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Where this is going
|
|
321
|
+
|
|
322
|
+
Named rather than left to be discovered:
|
|
323
|
+
|
|
324
|
+
- **No async client.** Everything blocks. If you are inside `asyncio`, run a
|
|
325
|
+
call in a thread (`await asyncio.to_thread(deliverd.approve, title=…)`) until
|
|
326
|
+
there is one.
|
|
327
|
+
- **`flows`, `comments` and `schedules` are not here yet.** Every primitive
|
|
328
|
+
takes `flow_id`, so a Python agent can join a flow; creating and completing
|
|
329
|
+
one is TypeScript, REST or MCP for now.
|
|
330
|
+
|
|
331
|
+
Both gaps are checked in this repository's own test suite, so they cannot
|
|
332
|
+
quietly become three.
|
|
333
|
+
|
|
334
|
+
## Also
|
|
335
|
+
|
|
336
|
+
- **REST** — `docs/api.md`, and an OpenAPI document at `/openapi.json`
|
|
337
|
+
- **MCP** — a remote server any agent can connect to, at `deliverd.dev/api/mcp`
|
|
338
|
+
- **TypeScript** — [`@deliverd/sdk`](https://www.npmjs.com/package/@deliverd/sdk),
|
|
339
|
+
the same shapes with `await`
|
|
340
|
+
- **CLI** — `npm i -g deliverd`, for a build step
|
|
341
|
+
|
|
342
|
+
MIT.
|
deliverd-0.1.0/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# deliverd
|
|
2
|
+
|
|
3
|
+
**Add human approval to any AI agent.** Ask a person, wait for the answer, act
|
|
4
|
+
on it.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install deliverd
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from deliverd import deliverd
|
|
12
|
+
|
|
13
|
+
decision = deliverd.approve(title="Refund £1,240 to Acme", approvers=["Finance"])
|
|
14
|
+
if decision.approved:
|
|
15
|
+
refund()
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That is the whole integration. The call blocks until somebody decides, and
|
|
19
|
+
returns whether they said yes.
|
|
20
|
+
|
|
21
|
+
What it stands in for: an approval page, the emails and the reminders, identity
|
|
22
|
+
and single sign-on, who is allowed to decide, a table to keep it in, an audit
|
|
23
|
+
trail, and the dashboard somebody uses to do it. None of that is yours to
|
|
24
|
+
build.
|
|
25
|
+
|
|
26
|
+
- **No dependencies.** The standard library has HTTP, HMAC and JSON. Nothing
|
|
27
|
+
new lands in your agent's environment.
|
|
28
|
+
- **Python 3.9 and up.**
|
|
29
|
+
- **Calls block.** The function that asks is the function that answers, which
|
|
30
|
+
is the shape an agent tool wants. There is no async client yet — see
|
|
31
|
+
[Where this is going](#where-this-is-going).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Before you have an account
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from deliverd import Deliverd
|
|
39
|
+
|
|
40
|
+
deliverd = Deliverd(mode="development")
|
|
41
|
+
decision = deliverd.approve(title="Deploy to production")
|
|
42
|
+
print(decision.approved) # True
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
No key, no network, no colleague to interrupt. The imaginary approver decides
|
|
46
|
+
instantly, and the four things that can happen to a real request are all one
|
|
47
|
+
line away:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
Deliverd(mode="development", development=DevelopmentOptions(outcome="rejected"))
|
|
51
|
+
Deliverd(mode="development", development=DevelopmentOptions(outcome="timeout"))
|
|
52
|
+
Deliverd(mode="development", development=DevelopmentOptions(outcome="question"))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
or `DELIVERD_DEV_OUTCOME=question` in the environment, so a whole test suite
|
|
56
|
+
flips without touching the code.
|
|
57
|
+
|
|
58
|
+
**`question` is the one people forget to write.** An approver can ask the agent
|
|
59
|
+
something before deciding, and until it is answered nothing moves:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
decision = deliverd.approve(
|
|
63
|
+
title="Refund £1,240 to Acme",
|
|
64
|
+
on_question=lambda q, approval: "Yes — staged on Tuesday",
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Return `None` from the handler to leave it for a person.
|
|
69
|
+
|
|
70
|
+
Development mode is a stand-in transport, not a branch inside the client, so
|
|
71
|
+
the retries, the error handling and the polling are the same code that runs in
|
|
72
|
+
production. It answers the calls the primitives make and returns 501 for
|
|
73
|
+
everything else, rather than pretending to be the whole API.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from deliverd import Deliverd
|
|
79
|
+
|
|
80
|
+
deliverd = Deliverd() # DELIVERD_API_KEY from the environment
|
|
81
|
+
deliverd = Deliverd(api_key="dlv_…") # or pass it
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Variable | What |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `DELIVERD_API_KEY` | A `dlv_` key from Settings → API tokens, or a `dlvo_` OAuth access token |
|
|
87
|
+
| `DELIVERD_BASE_URL` | Override for a self-hosted deployment |
|
|
88
|
+
| `DELIVERD_MODE` | `development` to run the loop offline |
|
|
89
|
+
| `DELIVERD_DEV_OUTCOME` | `approved` · `rejected` · `timeout` · `question` |
|
|
90
|
+
|
|
91
|
+
The module-level `deliverd` builds itself from the environment the first time
|
|
92
|
+
you touch it, so the shortest form has no setup line at all. Build a `Deliverd`
|
|
93
|
+
yourself when you need two keys, a different base URL, or development mode.
|
|
94
|
+
|
|
95
|
+
## The four verbs
|
|
96
|
+
|
|
97
|
+
| | You want | Call |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| **Approval** | May I do this? | `approve()` |
|
|
100
|
+
| **Confirmation** | Are you sure? — lower ceremony | `confirm()` |
|
|
101
|
+
| **Review** | Is what I made right? | `review()` |
|
|
102
|
+
| **Information** | A figure, a date, a choice | `collect()` |
|
|
103
|
+
| **Publishing** | People need to read this | `publish()` |
|
|
104
|
+
|
|
105
|
+
### Approve
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
decision = deliverd.approve(
|
|
109
|
+
title="Refund £1,240 to Acme",
|
|
110
|
+
description="Duplicate charge on invoice 4821.",
|
|
111
|
+
risk="high",
|
|
112
|
+
factors=[{"label": "Customer charged twice", "status": "warning"}],
|
|
113
|
+
links=[{"label": "Invoice 4821", "url": "https://…"}],
|
|
114
|
+
approvers=["Finance"], # a person, a team, a group — or omit it
|
|
115
|
+
expires_in="4h",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
decision.approved # True only for an approval
|
|
119
|
+
decision.status # approved | rejected | expired | cancelled
|
|
120
|
+
decision.note # their reason, when they gave one
|
|
121
|
+
decision.decided_by # "Sarah Chen" — a name, not an id
|
|
122
|
+
decision.decided_by_id # their user id, when the key is what you need
|
|
123
|
+
decision.url # the page they decided on
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**A refusal is an answer, not an error.** `approve()` returns on rejected,
|
|
127
|
+
expired and cancelled as well as approved; it raises only when the *wait*
|
|
128
|
+
failed — a timeout, a cancellation, or an API it could not reach.
|
|
129
|
+
|
|
130
|
+
`approvers` takes a person (email or user id), the name of a team or directory
|
|
131
|
+
group, or a word meaning the whole organisation. Naming a team is usually what
|
|
132
|
+
you want: a request addressed to one person waits for that person to come back
|
|
133
|
+
from holiday.
|
|
134
|
+
|
|
135
|
+
### Review
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
outcome = deliverd.review(
|
|
139
|
+
title="Q3 board pack",
|
|
140
|
+
report_id=report.id,
|
|
141
|
+
instructions="Check the figures against the ledger.",
|
|
142
|
+
reviewers=["partner@firm.example"],
|
|
143
|
+
)
|
|
144
|
+
if not outcome.approved:
|
|
145
|
+
revise(outcome.verdicts, outcome.thread_count)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Collect
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
answers = deliverd.collect(
|
|
152
|
+
title="Before I file the Q3 return",
|
|
153
|
+
respondents=["finance@firm.example"],
|
|
154
|
+
questions=[
|
|
155
|
+
{"prompt": "Headcount at 30 September", "kind": "number"},
|
|
156
|
+
{"prompt": "Any disposals in the quarter?", "kind": "boolean"},
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
if answers.complete:
|
|
160
|
+
file_return(answers["Headcount at 30 September"])
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`answers` is keyed by the question as you asked it. Check `complete` before you
|
|
164
|
+
use it: a request that expired has whatever arrived and no more.
|
|
165
|
+
|
|
166
|
+
### Publish
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
result = deliverd.publish(title="Weekly figures", content=html, audience=["Acme"])
|
|
170
|
+
if result.held:
|
|
171
|
+
print("Waiting on a person before anyone can open it")
|
|
172
|
+
else:
|
|
173
|
+
print(result.url)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Restarting safely
|
|
177
|
+
|
|
178
|
+
Give a request your own `external_id` and a restarted agent finds the request it
|
|
179
|
+
already filed rather than asking two people about one act:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
deliverd.approve(title="Refund £1,240", external_id=f"refund-{invoice.id}")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
That is best effort — two *concurrent* calls can still both find nothing. For
|
|
186
|
+
the guarantee, pass `idempotency_key`, which the server enforces.
|
|
187
|
+
|
|
188
|
+
## Waiting
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
approval = deliverd.approvals.create(title="Deploy production")
|
|
192
|
+
print("Waiting at", approval.url)
|
|
193
|
+
approval.wait(on_poll=lambda a: print(".", end=""), timeout="30m")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Polling backs off — a quick first look so a fast decision reads as one, then
|
|
197
|
+
somebody's afternoon rather than a progress bar. Pass a `threading.Event` as
|
|
198
|
+
`cancel` to stop a wait from another thread.
|
|
199
|
+
|
|
200
|
+
Every request expires, so a wait always ends: 24 hours unless you pass
|
|
201
|
+
`expires_in`.
|
|
202
|
+
|
|
203
|
+
> **Durations are seconds here, not milliseconds.** `timeout=30` is half a
|
|
204
|
+
> minute, because `time.sleep` takes seconds and so does everything else in
|
|
205
|
+
> Python. The string forms — `"30m"`, `"4h"`, `"2d"` — and `timedelta` mean the
|
|
206
|
+
> same thing in every Deliverd SDK. (The TypeScript package reads a bare number
|
|
207
|
+
> as milliseconds, for the mirror-image reason.)
|
|
208
|
+
|
|
209
|
+
## Errors
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from deliverd import DeliverdError, DeliverdTimeoutError
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
deliverd.approve(title="…")
|
|
216
|
+
except DeliverdTimeoutError:
|
|
217
|
+
... # you stopped waiting; the request is still open
|
|
218
|
+
except DeliverdError as err:
|
|
219
|
+
err.code # the API's own code: self_approval, rate_limited…
|
|
220
|
+
err.is_auth # 401 or 403
|
|
221
|
+
err.is_rate_limit # 429
|
|
222
|
+
err.is_not_found # 404
|
|
223
|
+
err.is_conflict # 409 — re-read and try again
|
|
224
|
+
err.hint # the one thing to do next
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Messages say what happened and what to do about it, rather than handing you a
|
|
228
|
+
code to search for.
|
|
229
|
+
|
|
230
|
+
## Webhooks
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from deliverd import construct_event, SIGNATURE_HEADER
|
|
234
|
+
|
|
235
|
+
@app.post("/webhooks/deliverd")
|
|
236
|
+
def receive():
|
|
237
|
+
event = construct_event(
|
|
238
|
+
secret=os.environ["DELIVERD_WEBHOOK_SECRET"],
|
|
239
|
+
body=request.get_data(as_text=True), # the RAW body
|
|
240
|
+
header=request.headers.get(SIGNATURE_HEADER),
|
|
241
|
+
)
|
|
242
|
+
if event.event == "approval.approved":
|
|
243
|
+
...
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`body` must be the raw request body. A re-encoded object is a different string
|
|
247
|
+
and will never verify — in FastAPI that is `(await request.body()).decode()`, in
|
|
248
|
+
Django `request.body.decode()`.
|
|
249
|
+
|
|
250
|
+
`construct_event` raises rather than returning `None`, because a route that
|
|
251
|
+
treats an unverifiable body as "no event" answers 200 and tells whoever is
|
|
252
|
+
posting that everything is fine.
|
|
253
|
+
|
|
254
|
+
## Lists
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
deliverd.approvals.list() # every one, following the cursor
|
|
258
|
+
deliverd.approvals.list(limit=20) # one page of twenty
|
|
259
|
+
page = deliverd.approvals.list_page(limit=20)
|
|
260
|
+
page.items, page.next_cursor
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**A `limit` means one page. No `limit` means all of them.** A full page and a
|
|
264
|
+
complete list are otherwise indistinguishable until somebody counts.
|
|
265
|
+
|
|
266
|
+
## Publishing (maintainers)
|
|
267
|
+
|
|
268
|
+
Tag and push; GitHub Actions builds, re-runs the suite on Python 3.9, and uploads
|
|
269
|
+
through PyPI Trusted Publishing — there is no API token anywhere.
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
git tag python-v0.1.0 && git push origin python-v0.1.0
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Where this is going
|
|
276
|
+
|
|
277
|
+
Named rather than left to be discovered:
|
|
278
|
+
|
|
279
|
+
- **No async client.** Everything blocks. If you are inside `asyncio`, run a
|
|
280
|
+
call in a thread (`await asyncio.to_thread(deliverd.approve, title=…)`) until
|
|
281
|
+
there is one.
|
|
282
|
+
- **`flows`, `comments` and `schedules` are not here yet.** Every primitive
|
|
283
|
+
takes `flow_id`, so a Python agent can join a flow; creating and completing
|
|
284
|
+
one is TypeScript, REST or MCP for now.
|
|
285
|
+
|
|
286
|
+
Both gaps are checked in this repository's own test suite, so they cannot
|
|
287
|
+
quietly become three.
|
|
288
|
+
|
|
289
|
+
## Also
|
|
290
|
+
|
|
291
|
+
- **REST** — `docs/api.md`, and an OpenAPI document at `/openapi.json`
|
|
292
|
+
- **MCP** — a remote server any agent can connect to, at `deliverd.dev/api/mcp`
|
|
293
|
+
- **TypeScript** — [`@deliverd/sdk`](https://www.npmjs.com/package/@deliverd/sdk),
|
|
294
|
+
the same shapes with `await`
|
|
295
|
+
- **CLI** — `npm i -g deliverd`, for a build step
|
|
296
|
+
|
|
297
|
+
MIT.
|