Skip to main content

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 — return client.my_jwks()
  • GET /manifest.json — return your manifest dict (see Manifest below)
  • POST /inbound — verify with client.verify_inbound(body); dispatch by env["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_version must be '0.1' or '0.2'
  • iss must match the iss Choir has registered for your partner
  • Tool names: lowercase, digits, ., _, - only; unique within the manifest
  • risk must be 'safe' or 'sensitive'
  • input_schema must 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):

  1. fetches Choir's JWKS at {choir_base_url}/cap/{workspace_slug}/.well-known/jwks.json (cached per workspace);
  2. reconstructs the canonical signed input from the envelope sans signature;
  3. verifies the ES256 signature;
  4. checks iat isn't in the future and exp hasn't passed.

On signature failure, it refreshes the JWKS once (handles key rotation) before failing.

Wire format details

For the gory details:

  • JCS canonicalizationjcs() 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 is header_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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cap_partner-0.2.0.tar.gz (12.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cap_partner-0.2.0-py3-none-any.whl (14.2 kB view details)

Uploaded Python 3

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

Hashes for cap_partner-0.2.0.tar.gz
Algorithm Hash digest
SHA256 0cd9091220804548877e9d95ab7e2d6b0f4bd6deecb65ae05cdfc7e52148aaa0
MD5 33b7eb57102813c73b69ccd60df7d73b
BLAKE2b-256 cfe49de799576f54a11f584beb451b596843a22e484b3e5bc37780780b0d6b4b

See more details on using hashes here.

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

Hashes for cap_partner-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3ca43816d63275e5b67e60e3293ddc0d8495625a9ec305926aa74f987ac02368
MD5 a4fe9cedf766cee8207f08d3bc28e9f5
BLAKE2b-256 d344763ed3f6a8463b6466929fb9640f9ea9ec1a7c4b35715848c692631c34f0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page