Client-side tunnel SDK for the Butt-Dial communication service — outbound, inbound callbacks, retry orchestration, and end-to-end diagnostic.
Project description
butt-dial-sdk
A stateless client-side tunnel to the Butt-Dial communication service for Python agentic systems. Ships outbound messaging, inbound callbacks, retry orchestration, an admin router, and a staged diagnostic with actionable remediation.
Status: Alpha (0.1.1 on PyPI). In production via iv-bknd.
Install
pip install butt-dial-sdk # core: Client, InboundHandler, RetryWorker, doctor CLI
pip install 'butt-dial-sdk[router]' # + FastAPI admin router
Python 3.11+. 125/125 tests green, 86% coverage.
Quickstart — send a message in 3 lines
Set two env vars:
export BUTT_DIAL_URL=https://your-server.example/sse
export BUTT_DIAL_TOKEN=your-org-token
Send:
from buttdial import Client
bd = Client.from_env()
result = await bd.send_message(to="+14155550123", body="Hello from Python")
print(result.message_sid) # "SM..." on success
That's it. See the examples/ folder for full working code.
What's included
The SDK bundles everything an agentic system needs to integrate with Butt-Dial reliably:
| Component | What it does |
|---|---|
Client |
Outbound send_message(), list_tools(), ping(), fetch_usage_summary(). MCP/SSE transport with 3-attempt exponential-backoff retry and token resolution (agent > org > legacy). |
InboundHandler |
Decorator-based callbacks (@on_message, @on_delivery_receipt, @on_status_update). Ships WhatsApp payload parser + signature verification + subscription-challenge verification. Handler errors isolated. |
RetryWorker |
Background retry loop against a host-provided FailedMessageRepository protocol. Pluggable 2^n-minute backoff. Dead-letter transition with an optional on_dead_letter hook. |
make_router() |
FastAPI admin router: 7 endpoints for operators (overview, agents list, activate/deactivate, failed-message list/retry/dismiss). Plugs in via AgentDirectory + AdminFailedMessageRepository protocols. |
run_diagnostic() / buttdial doctor |
7-stage end-to-end health check with per-stage remediation messages. Runs as CLI or pytest fixture. |
FakeButtDialServer |
Programmable in-process fake for integration tests. Monkeypatches MCP at import boundary; no external process. |
AccountsClient |
REST wrapper for onboarding + owner-token lifecycle: register → verify-email → set_phone → OTP → confirm_phone → rotate_token. Typed exceptions per server error code. |
Connect butt-dial
Onboard a new tenant onto Butt-Dial and get their first owner bearer token.
Once this cycle is done, the rest of provisioning (buying phones / emails /
SMS numbers, activating IM devices) happens through Client and the admin
console:
from buttdial import AccountsClient
accounts = AccountsClient("https://call.95percent.ai")
# 1. Register — the server emails a verification link.
await accounts.register("Acme Inc", "alice@acme.com")
# … user clicks the link; your app's deep-link handler extracts the token …
# 2. Claim the owner token.
info = await accounts.verify(verification_token)
vault.put(tenant_id, info["owner_token"])
authed = accounts.with_token(info["owner_token"])
# 3. Register an admin phone (prerequisite for rotation).
challenge = await authed.set_phone("+14155550123")
await authed.confirm_phone(challenge["challenge_id"], otp_from_user)
# 4. Later — rotate. The old token is revoked atomically.
new = await authed.rotate_token(otp_prompt=lambda hint:
input(f"Enter code sent to {hint}: "))
vault.put(tenant_id, new["token"])
How it fits together
The SDK is a stateless tunnel. It owns the Butt-Dial protocol and retry orchestration. The host app owns all persistence and identity via two protocols:
from buttdial import (
Client, InboundHandler, RetryWorker,
FailedMessageRepository, # protocol — host implements
AgentDirectory, # protocol — host implements
make_router,
)
# 1. Build a client (once, at startup).
bd = Client.from_env()
# 2. Outbound — call anywhere.
result = await bd.send_message(to="+1...", body="hi")
# 3. Inbound — register handlers, mount the router.
inbound = InboundHandler(whatsapp_verify_token="...", whatsapp_webhook_secret="...")
@inbound.on_message(channel="whatsapp")
async def on_msg(msg):
await process(msg.sender, msg.text)
app.include_router(inbound.router, prefix="/api/webhook")
# 4. Retry — implement the 4 repo methods, start the worker.
class MyRepo:
async def fetch_due(self, limit): ...
async def mark_delivered(self, msg_id, message_sid, retry_count): ...
async def increment_retry(self, msg_id, retry_count, error, next_retry_at): ...
async def mark_dead(self, msg_id, retry_count, error): ...
worker = RetryWorker(repo=MyRepo(), client=bd)
await worker.start()
# 5. Admin router (optional) — adds operator endpoints.
class MyDirectory:
async def list_agents(self): ...
async def overview_snapshot(self): ...
async def activate(self, agent_id): ...
async def deactivate(self, agent_id): ...
app.include_router(
make_router(client=bd, repo=MyRepo(), directory=MyDirectory()),
prefix="/api/butt-dial",
)
Diagnostic: buttdial doctor
Seven staged checks with remediation for common failure modes:
$ buttdial doctor --to +14155550123
[✓] Config loaded — url=https://...
[✓] Server reachable (142ms) — HTTP 200
[✓] SSE handshake (310ms)
[✓] Tool list — 7 tool(s), includes comms_send_message
[✗] send_message accepted (656ms) — agentId is required (or use a client token)
→ This tool needs agent context. Pass --agent-id <per-agent token>
to the CLI, or send with agent_token=... from code.
Flags:
--to +E.164— recipient for stages 5-7--agent-id <token>— per-agent token for tools that require it (since 0.1.1)--ascii— use[OK]/[FAIL]instead of Unicode checkmarks (auto on Windows Git Bash)--expect-ack— wait for human reply on stage 7
Each failure includes a one-line remediation drawn from a curated map: 401/403/429, connection refused, timeouts, DNS, SSL, invalid recipient, missing token, agentId required, consent, rate limit, unprovisioned channel. Exit code is non-zero on any stage failure — wire it into CI as a live integration canary.
Server-returned errors (e.g. {"error": "..."} response bodies) are surfaced verbatim into SendResult.error — no more "no message_sid returned" silence (fixed in 0.1.1).
Testing with FakeButtDialServer
No real server required:
from buttdial import Client
from buttdial.testing import fake_server # pytest fixture
async def test_my_integration(fake_server):
fake_server.on_send(sid="SM-42")
c = Client(token="t", url="https://fake.test/sse")
result = await c.send_message(to="+1", body="hi")
assert result.message_sid == "SM-42"
assert fake_server.sent_messages[0].args["to"] == "+1"
Program error paths:
fake_server.on_send(error="connection refused", count=3) # exhausts retries
fake_server.on_send(error="transient", count=2) # recovers on 3rd
fake_server.on_send(sid="SM-recovered")
Simulate inbound events end-to-end:
await fake_server.simulate_inbound(inbound, sender="+1", text="hello", channel="whatsapp")
await fake_server.simulate_receipt(inbound, message_sid="SM-42", status="delivered")
Design principles
- Stateless — SDK owns no DB, no files, no global state beyond a configured
Client. - Protocols, not base classes — host implements
FailedMessageRepository,AgentDirectoryasProtocols. SDK never queries the DB. - Errors are reported, not raised —
SendResult.failed/error/stage_failed/remediationis the primary surface. The few places the SDK raises inherit fromButtDialError. - Async-first — built on
asyncio,httpx,mcp. - Easy to test —
FakeButtDialServercovers every MCP call; no port allocation, no external process.
Documentation
docs/SPEC.md— full architecture and public API contract.docs/TODO.md— implementation roadmap (11 phases; 1-10 done).docs/DECISIONS.md— design decision log with rationale.docs/ERRORS.md— known pitfalls and their remediations.docs/REASONING.md— debugging breadcrumb trails.docs/CHANGELOG.md— release notes.
License
MIT — free for commercial use, no obligations.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file butt_dial_sdk-0.2.0.tar.gz.
File metadata
- Download URL: butt_dial_sdk-0.2.0.tar.gz
- Upload date:
- Size: 67.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
81d147c422f1c1d9fd64707f1d830770325309ff8ae1fa0d5974df346eea9244
|
|
| MD5 |
c32eb3a0ed3a1943f1deca22b0fee66f
|
|
| BLAKE2b-256 |
f0fa57b2f7f7813574c6470af0e9e9f4c84ee6d26522d5d8ce4ddf71c5e6ea76
|
File details
Details for the file butt_dial_sdk-0.2.0-py3-none-any.whl.
File metadata
- Download URL: butt_dial_sdk-0.2.0-py3-none-any.whl
- Upload date:
- Size: 40.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6be9d2442209aa4397071721c3cf6b827dc911be74e27182d57e889d3188d0aa
|
|
| MD5 |
362bc296bd66d6d76548cd9eb38f35dc
|
|
| BLAKE2b-256 |
a157a982fae1adc9ec00e7fbcc4fde5ae28ac52cd191924a37b491add821ad3b
|