Python SDK for the Choir CAP (Channel-Aware Partner) protocol — build partner integrations that participate in Choir channels as first-class members.
Project description
cap-partner (Python SDK)
Minimal SDK for integrating any Python service with Choir over the CAP (Channel-Aware Partner) protocol v0.2.
If you're building a partner that needs to:
- post messages or events into Choir channels;
- propose actions humans must approve before you execute them;
- expose tools Choir Voices can call;
- or receive notifications when channel state changes —
this is the package.
Install
pip install cap-partner # core only
pip install 'cap-partner[fastapi]' # adds FastAPI + uvicorn for examples/
pip install 'cap-partner[dev]' # adds pytest for the test suite
Dependencies: just cryptography (for ES256 signing) and httpx (for HTTP). Both are widely audited.
Quick start
1. Generate a signing key (once)
from cap_partner import generate_es256_keypair
pem, key = generate_es256_keypair()
open("partner.key", "w").write(pem) # store it somewhere safe
In production, generate the key out-of-band and load it from your secrets manager. Don't generate per-restart — Choir caches your JWKS and an ephemeral key invalidates the cache.
2. Register your partner with Choir
A Choir staff member runs POST /cap/partners with:
{
"iss": "my.partner.io",
"name": "My Partner",
"manifest_url": "https://my.partner.io/manifest.json",
"jwks_url": "https://my.partner.io/jwks.json",
"inbound_url": "https://my.partner.io/inbound"
}
Then each workspace admin separately approves the connection + grants you channel scopes.
3. Wire the partner
from cap_partner import CapPartnerClient, CapPartnerConfig
client = CapPartnerClient(CapPartnerConfig(
iss="my.partner.io",
kid="2026-q2",
private_key_pem=open("partner.key").read(),
choir_base_url="https://choir.example.com",
))
# Post a message
client.send_say(workspace="acme", channel="ops", body="Hello from my partner.")
# Post an event
client.send_event(
workspace="acme", channel="alerts",
event_type="order.delivered",
attrs={"order_id": "4471", "carrier": "DHL"},
)
# Propose an action a human in the channel must approve
client.send_propose(
workspace="acme", channel="finance-approvals",
proposal_id="refund-4471",
title="Refund order #4471",
description="$234 — customer reports defective product",
action={
"tool_name": "my.refund.execute",
"args": {"order_id": "4471", "amount": 234},
},
)
When a human approves or rejects the proposal, Choir POSTs a proposal.decided event envelope to your /inbound.
4. Serve the three required endpoints
See examples/sample_partner.py for a complete FastAPI implementation — copy + adapt.
GET /jwks.json— returnclient.my_jwks()GET /manifest.json— return your manifest dict (see Manifest below)POST /inbound— verify withclient.verify_inbound(body); dispatch byenv["turn"]
Manifest
Your /manifest.json declares what tools the partner advertises. Choir admins refresh it from the admin UI; the cached version drives the per-channel tool grants.
MANIFEST = {
"cap_version": "0.2",
"iss": "my.partner.io",
"name": "My Partner",
"description": "What my partner does",
"vendor": {
"name": "My Company",
"url": "https://my.company",
"contact": "support@my.company",
},
"tools": [
{
"name": "my.search", # lowercase + ._- only, globally unique within your manifest
"title": "Search my catalog",
"description": "Searches my product catalog by name or category.",
"input_schema": { # JSON Schema; Choir + tools-call partners validate against this
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
"risk": "safe", # 'safe' or 'sensitive'
},
{
"name": "my.refund.execute",
"title": "Execute refund",
"description": "Refunds a transaction. Should be invoked through propose, not direct tool_request.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount": {"type": "number"},
},
"required": ["order_id", "amount"],
},
"risk": "sensitive",
"requires_propose": True, # hint to Choir UI; not enforced server-side yet
},
],
"subscribes_to_events": ["proposal.decided"], # which Choir-side events you want pushed to /inbound
}
Validation rules (full list in envelope.py + enforced by Choir's manifest service):
cap_versionmust be'0.1'or'0.2'issmust match the iss Choir has registered for your partner- Tool names: lowercase, digits,
.,_,-only; unique within the manifest riskmust be'safe'or'sensitive'input_schemamust be a JSON object (typically a JSON Schema)
Turn types (v0.2)
| Turn | Direction | What it's for |
|---|---|---|
say |
both | A normal message body in a channel |
event |
both | A structured notification ({event_type, attrs}) |
propose |
partner → Choir | An action awaiting human approval |
tool_request |
both | Ask the other side to run a tool |
tool_result |
both | Response to a tool_request, correlated by call_id |
Other turn types (ask, subscribe, transfer, escalate) are reserved and currently return turn_type_not_supported_in_v0.
Verifying inbound envelopes
Choir signs every envelope it sends you. client.verify_inbound(env):
- fetches Choir's JWKS at
{choir_base_url}/cap/{workspace_slug}/.well-known/jwks.json(cached per workspace); - reconstructs the canonical signed input from the envelope sans
signature; - verifies the ES256 signature;
- checks
iatisn't in the future andexphasn't passed.
On signature failure, it refreshes the JWKS once (handles key rotation) before failing.
Wire format details
For the gory details:
- JCS canonicalization —
jcs()in envelope.py. RFC 8785 subset. Mirrors choir-backend's TypeScript implementation byte-for-byte. - Detached compact JWS — header is
{alg: "ES256", typ: "cap-envelope+jws", kid: "..."}. Signature isheader_b64..signature_b64(empty middle segment). - JWKS — standard JWK Set with EC P-256 keys.
If you're implementing a non-Python partner, the test test_canonical_form_matches_typescript_reference in tests/test_envelope.py is the cross-implementation anchor.
Running the sample partner
pip install 'cap-partner[fastapi]'
python examples/sample_partner.py
# or: uvicorn examples.sample_partner:app --port 9001 --reload
Set CHOIR_BASE_URL to your Choir instance + (for production) supply a stable CAP_PRIVATE_KEY_PEM.
Running tests
pip install 'cap-partner[dev]'
pytest
License
MIT.
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 cap_partner-0.2.0.tar.gz.
File metadata
- Download URL: cap_partner-0.2.0.tar.gz
- Upload date:
- Size: 12.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0cd9091220804548877e9d95ab7e2d6b0f4bd6deecb65ae05cdfc7e52148aaa0
|
|
| MD5 |
33b7eb57102813c73b69ccd60df7d73b
|
|
| BLAKE2b-256 |
cfe49de799576f54a11f584beb451b596843a22e484b3e5bc37780780b0d6b4b
|
File details
Details for the file cap_partner-0.2.0-py3-none-any.whl.
File metadata
- Download URL: cap_partner-0.2.0-py3-none-any.whl
- Upload date:
- Size: 14.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3ca43816d63275e5b67e60e3293ddc0d8495625a9ec305926aa74f987ac02368
|
|
| MD5 |
a4fe9cedf766cee8207f08d3bc28e9f5
|
|
| BLAKE2b-256 |
d344763ed3f6a8463b6466929fb9640f9ea9ec1a7c4b35715848c692631c34f0
|