Metadata-Version: 2.4
Name: cap-partner
Version: 0.2.0
Summary: Python SDK for the Choir CAP (Channel-Aware Partner) protocol — build partner integrations that participate in Choir channels as first-class members.
Project-URL: Homepage, https://github.com/Mukopaje/choir/tree/main/cap-sdks/python#readme
Project-URL: Source, https://github.com/Mukopaje/choir
Project-URL: Issues, https://github.com/Mukopaje/choir/issues
Project-URL: Documentation, https://github.com/Mukopaje/choir/blob/main/cap-sdks/PARTNER_INTEGRATION.md
Author-email: Choir <engineering@choir.example>
License: MIT
License-File: LICENSE
Keywords: agent,cap,cap-protocol,channel-aware-partner,choir,es256,jws,partner-sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography>=42.0
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
Requires-Dist: uvicorn[standard]>=0.27; extra == 'fastapi'
Description-Content-Type: text/markdown

# 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

```sh
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)

```python
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:

```json
{
  "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

```python
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`](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.

```python
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`](cap_partner/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 canonicalization** — [`jcs()` in envelope.py](cap_partner/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](tests/test_envelope.py) is the cross-implementation anchor.

## Running the sample partner

```sh
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

```sh
pip install 'cap-partner[dev]'
pytest
```

## License

MIT.
