deliverd
Add human approval to any AI agent. Ask a person, wait for the answer, act on it.
pip install deliverd
from deliverd import deliverd
decision = deliverd.approve(title="Refund £1,240 to Acme", approvers=["Finance"])
if decision.approved:
refund()
That is the whole integration. The call blocks until somebody decides, and returns whether they said yes.
What it stands in for: an approval page, the emails and the reminders, identity and single sign-on, who is allowed to decide, a table to keep it in, an audit trail, and the dashboard somebody uses to do it. None of that is yours to build.
- No dependencies. The standard library has HTTP, HMAC and JSON. Nothing new lands in your agent's environment.
- Python 3.9 and up.
- Calls block. The function that asks is the function that answers, which is the shape an agent tool wants. There is no async client yet — see Where this is going.
Before you have an account
from deliverd import Deliverd
deliverd = Deliverd(mode="development")
decision = deliverd.approve(title="Deploy to production")
print(decision.approved) # True
No key, no network, no colleague to interrupt. The imaginary approver decides instantly, and the four things that can happen to a real request are all one line away:
Deliverd(mode="development", development=DevelopmentOptions(outcome="rejected"))
Deliverd(mode="development", development=DevelopmentOptions(outcome="timeout"))
Deliverd(mode="development", development=DevelopmentOptions(outcome="question"))
or DELIVERD_DEV_OUTCOME=question in the environment, so a whole test suite
flips without touching the code.
question is the one people forget to write. An approver can ask the agent
something before deciding, and until it is answered nothing moves:
decision = deliverd.approve(
title="Refund £1,240 to Acme",
on_question=lambda q, approval: "Yes — staged on Tuesday",
)
Return None from the handler to leave it for a person.
Development mode is a stand-in transport, not a branch inside the client, so the retries, the error handling and the polling are the same code that runs in production. It answers the calls the primitives make and returns 501 for everything else, rather than pretending to be the whole API.
Configuration
from deliverd import Deliverd
deliverd = Deliverd() # DELIVERD_API_KEY from the environment
deliverd = Deliverd(api_key="dlv_…") # or pass it
| Variable | What |
|---|---|
DELIVERD_API_KEY |
A dlv_ key from Settings → API tokens, or a dlvo_ OAuth access token |
DELIVERD_BASE_URL |
Override for a self-hosted deployment |
DELIVERD_MODE |
development to run the loop offline |
DELIVERD_DEV_OUTCOME |
approved · rejected · timeout · question |
The module-level deliverd builds itself from the environment the first time
you touch it, so the shortest form has no setup line at all. Build a Deliverd
yourself when you need two keys, a different base URL, or development mode.
The four verbs
| You want | Call | |
|---|---|---|
| Approval | May I do this? | approve() |
| Confirmation | Are you sure? — lower ceremony | confirm() |
| Review | Is what I made right? | review() |
| Information | A figure, a date, a choice | collect() |
| Publishing | People need to read this | publish() |
Approve
decision = deliverd.approve(
title="Refund £1,240 to Acme",
description="Duplicate charge on invoice 4821.",
risk="high",
factors=[{"label": "Customer charged twice", "status": "warning"}],
links=[{"label": "Invoice 4821", "url": "https://…"}],
approvers=["Finance"], # a person, a team, a group — or omit it
expires_in="4h",
)
decision.approved # True only for an approval
decision.status # approved | rejected | expired | cancelled
decision.note # their reason, when they gave one
decision.decided_by # "Sarah Chen" — a name, not an id
decision.decided_by_id # their user id, when the key is what you need
decision.url # the page they decided on
A refusal is an answer, not an error. approve() returns on rejected,
expired and cancelled as well as approved; it raises only when the wait
failed — a timeout, a cancellation, or an API it could not reach.
approvers takes a person (email or user id), the name of a team or directory
group, or a word meaning the whole organisation. Naming a team is usually what
you want: a request addressed to one person waits for that person to come back
from holiday.
Review
outcome = deliverd.review(
title="Q3 board pack",
report_id=report.id,
instructions="Check the figures against the ledger.",
reviewers=["partner@firm.example"],
)
if not outcome.approved:
revise(outcome.verdicts, outcome.thread_count)
Collect
answers = deliverd.collect(
title="Before I file the Q3 return",
respondents=["finance@firm.example"],
questions=[
{"prompt": "Headcount at 30 September", "kind": "number"},
{"prompt": "Any disposals in the quarter?", "kind": "boolean"},
],
)
if answers.complete:
file_return(answers["Headcount at 30 September"])
answers is keyed by the question as you asked it. Check complete before you
use it: a request that expired has whatever arrived and no more.
Publish
result = deliverd.publish(title="Weekly figures", content=html, audience=["Acme"])
if result.held:
print("Waiting on a person before anyone can open it")
else:
print(result.url)
Restarting safely
Give a request your own external_id and a restarted agent finds the request it
already filed rather than asking two people about one act:
deliverd.approve(title="Refund £1,240", external_id=f"refund-{invoice.id}")
That is best effort — two concurrent calls can still both find nothing. For
the guarantee, pass idempotency_key, which the server enforces.
Waiting
approval = deliverd.approvals.create(title="Deploy production")
print("Waiting at", approval.url)
approval.wait(on_poll=lambda a: print(".", end=""), timeout="30m")
Polling backs off — a quick first look so a fast decision reads as one, then
somebody's afternoon rather than a progress bar. Pass a threading.Event as
cancel to stop a wait from another thread.
Every request expires, so a wait always ends: 24 hours unless you pass
expires_in.
Durations are seconds here, not milliseconds.
timeout=30is half a minute, becausetime.sleeptakes seconds and so does everything else in Python. The string forms —"30m","4h","2d"— andtimedeltamean the same thing in every Deliverd SDK. (The TypeScript package reads a bare number as milliseconds, for the mirror-image reason.)
Errors
from deliverd import DeliverdError, DeliverdTimeoutError
try:
deliverd.approve(title="…")
except DeliverdTimeoutError:
... # you stopped waiting; the request is still open
except DeliverdError as err:
err.code # the API's own code: self_approval, rate_limited…
err.is_auth # 401 or 403
err.is_rate_limit # 429
err.is_not_found # 404
err.is_conflict # 409 — re-read and try again
err.hint # the one thing to do next
Messages say what happened and what to do about it, rather than handing you a code to search for.
Webhooks
from deliverd import construct_event, SIGNATURE_HEADER
@app.post("/webhooks/deliverd")
def receive():
event = construct_event(
secret=os.environ["DELIVERD_WEBHOOK_SECRET"],
body=request.get_data(as_text=True), # the RAW body
header=request.headers.get(SIGNATURE_HEADER),
)
if event.event == "approval.approved":
...
body must be the raw request body. A re-encoded object is a different string
and will never verify — in FastAPI that is (await request.body()).decode(), in
Django request.body.decode().
construct_event raises rather than returning None, because a route that
treats an unverifiable body as "no event" answers 200 and tells whoever is
posting that everything is fine.
Lists
deliverd.approvals.list() # every one, following the cursor
deliverd.approvals.list(limit=20) # one page of twenty
page = deliverd.approvals.list_page(limit=20)
page.items, page.next_cursor
A limit means one page. No limit means all of them. A full page and a
complete list are otherwise indistinguishable until somebody counts.
Publishing (maintainers)
Tag and push; GitHub Actions builds, re-runs the suite on Python 3.9, and uploads through PyPI Trusted Publishing — there is no API token anywhere.
git tag python-v0.1.0 && git push origin python-v0.1.0
Where this is going
Named rather than left to be discovered:
- No async client. Everything blocks. If you are inside
asyncio, run a call in a thread (await asyncio.to_thread(deliverd.approve, title=…)) until there is one. flows,commentsandschedulesare not here yet. Every primitive takesflow_id, so a Python agent can join a flow; creating and completing one is TypeScript, REST or MCP for now.
Both gaps are checked in this repository's own test suite, so they cannot quietly become three.
Also
- REST —
docs/api.md, and an OpenAPI document at/openapi.json - MCP — a remote server any agent can connect to, at
deliverd.dev/api/mcp - TypeScript —
@deliverd/sdk, the same shapes withawait - CLI —
npm i -g deliverd, for a build step
MIT.
Release files for deliverd 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| deliverd-0.1.0.tar.gz | 35.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| deliverd-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.2 kB
Release files / deliverd-0.1.0.tar.gz
| Download URL | deliverd-0.1.0.tar.gz |
|---|---|
| Size | 35.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d34bcf2a2817e539b004bdfc271b76ede814b1ab30514d081fb22245a6efe75a
|
|
BLAKE2b-256 checksum How to use checksums |
a4243f947178d8ac159c4e3145d52a9de631f1cfd31095d7a8b3498487a27e46
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / deliverd-0.1.0-py3-none-any.whl
| Download URL | deliverd-0.1.0-py3-none-any.whl |
|---|---|
| Size | 45.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f93300bbbf441922857d558b95f0c5322f14e571de8f9f1cc6b65a8519d01ed9
|
|
BLAKE2b-256 checksum How to use checksums |
4fa1d756ca5ffe1fed33130c9696c7e8352898f497d2d22e09a31363d5b05827
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log