Home/CAP integration

Integrating your product with Choir over CAP

Welcome. If you're reading this, your team is building a CAP partner — your product is about to become a first-class participant in Choir channels. This is the single doc you need; everything else is reference.

Estimated time to first signed envelope: 30 minutes.


Contents

  1. What CAP is, in one minute
  2. Pick your stack
  3. Install the SDK locally — Python + TypeScript
  4. Configure your environment
  5. Stand up your three endpoints
  6. Write your manifest — includes a realistic example
  7. Register with Choir
  8. Send your first envelope
  9. Handle inbound envelopes from Choir
  10. The propose pattern — human-in-loop for sensitive actions
  11. Tool grants — what admins control
  12. Testing the round-trip
  13. FAQ + troubleshooting
  14. What's coming

1. What CAP is, in one minute

CAP = Channel-Aware Partner protocol. It's how external systems join Choir workspaces as members who can:

  • post messages into channels (say)
  • fire structured events that get surfaced as system messages (event)
  • propose actions that humans in the channel must approve before you execute them (propose)
  • call tools advertised by another participant + receive the result (tool_request / tool_result)

Every envelope is a JSON object signed with your ES256 private key. Choir verifies against the public key you publish at /jwks.json. Workspaces explicitly approve each partner per-channel, and admins grant individual tools per-channel — so the access model is fine-grained out of the box.

You don't have to build any chat UI. Choir already has channels, threads, mentions, reactions, search, mobile. You ship envelopes; Choir is where humans + AI + other partners see them.


2. Pick your stack

We ship two SDKs that are wire-format identical:

If your service isUseReference sample
Python (FastAPI, Django, Flask, scripts)cap-partnerexamples/sample_partner.py
TypeScript / Node (Express, NestJS, Fastify, scripts)@choirhq/cap-partnerexamples/sample-partner.ts

Both SDKs:

  • speak the same wire protocol (an envelope signed in one verifies in the other)
  • have a verify_inbound / verifyInbound helper that handles Choir's JWKS fetch + caching + rotation
  • include builders for every turn type
  • have a passing test suite covering the canonical-form contract

Building in a language we don't ship? Use either SDK as a reference implementation — both are <500 LOC, readable top-to-bottom. The wire format is fully specified in the cap-protocol-spec KB article (also see §13).


3. Install the SDK

Both SDKs are published to their respective public registries. One-liner install — no local checkout needed.

Python

pip install cap-partner

Pin a version in requirements.txt like any other dependency:

cap-partner~=0.2

Verify the install

>>> from cap_partner import CapPartnerClient, generate_es256_keypair
>>> pem, _ = generate_es256_keypair()
>>> print(pem[:32])
-----BEGIN PRIVATE KEY-----

TypeScript / Node

npm install @choirhq/cap-partner
# or: pnpm add @choirhq/cap-partner
# or: yarn add @choirhq/cap-partner

Verify the install

import { CapPartnerClient, generateEs256Keypair } from '@choirhq/cap-partner';
const { pem } = await generateEs256Keypair();
console.log(pem.slice(0, 32));   // "-----BEGIN PRIVATE KEY-----"

Installing from local source (SDK contributors only)

You only need this section if you're modifying the SDK itself and want to test changes locally before publishing. Regular partners should use the registry install above.

Substitute <CHOIR_REPO> for the path to your local Choir checkout (e.g. /Users/alice/work/choir).

Python (editable):

pip install -e <CHOIR_REPO>/cap-sdks/python

TypeScript:

npm install <CHOIR_REPO>/cap-sdks/typescript

For TypeScript, contributors must rebuild after source changes:

cd <CHOIR_REPO>/cap-sdks/typescript && npm run build

The dist/ directory ships in the source tree so registry installs work without a build step on the consumer's side; contributors keep dist in sync with source via the rebuild step.


4. Configure your environment

Generate your signing key ONCE and store it in your secrets manager. Don't generate per-restart — Choir caches your JWKS, and an ephemeral key invalidates the cache + breaks inbound verification.

# Run once, save the output somewhere safe:
from cap_partner import generate_es256_keypair
pem, _ = generate_es256_keypair()
print(pem)

Then in your service env:

# Required
CAP_ISS="your.partner.io"            # stable issuer — what Choir registers your partner as
CAP_KID="2026-q2"                    # your current signing key id (use a date or version)
CAP_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
CHOIR_BASE_URL="https://choir.example.com"

# Optional
CAP_TIMEOUT_MS=10000                 # per-request HTTP timeout (default 10s)

Multi-line PEMs in shell envs are awkward. Two practical patterns:

  • Store the PEM in a file (/etc/secrets/cap-partner.key) + read it at boot.
  • For Docker / Kubernetes, mount as a file via secrets/configmap rather than -e CAP_PRIVATE_KEY_PEM=....

5. Stand up your three endpoints

Choir expects three HTTP endpoints from every partner. They're all GET except /inbound:

EndpointVerbReturnsAuth
/jwks.jsonGETYour public JWKS (so Choir can verify your envelopes)None — public
/manifest.jsonGETWhat tools you advertise + vendor metadataNone — public
/inboundPOSTReceives signed envelopes from ChoirVerified via envelope signature

The path doesn't have to be exactly /jwks.json etc. — you tell Choir the URLs when you register (see §7). But these three resources MUST exist.

Python (FastAPI) skeleton

from fastapi import FastAPI, HTTPException, Request
from cap_partner import CapPartnerClient, CapPartnerConfig
import os

client = CapPartnerClient(CapPartnerConfig(
    iss=os.environ["CAP_ISS"],
    kid=os.environ["CAP_KID"],
    private_key_pem=os.environ["CAP_PRIVATE_KEY_PEM"],
    choir_base_url=os.environ["CHOIR_BASE_URL"],
))

app = FastAPI()

@app.get("/jwks.json")
def jwks():
    return client.my_jwks()

@app.get("/manifest.json")
def manifest():
    return MANIFEST  # defined in §6

@app.post("/inbound")
async def inbound(request: Request):
    body = await request.json()
    try:
        env = client.verify_inbound(body)
    except ValueError as e:
        raise HTTPException(401, str(e))
    # dispatch by env["turn"] — see §9
    return {"ok": True}

TypeScript (Express) skeleton

import express from 'express';
import { CapPartnerClient } from '@choirhq/cap-partner';

const client = new CapPartnerClient({
    iss: process.env.CAP_ISS!,
    kid: process.env.CAP_KID!,
    privateKeyPem: process.env.CAP_PRIVATE_KEY_PEM!,
    choirBaseUrl: process.env.CHOIR_BASE_URL!,
});

const app = express();
app.use(express.json({ limit: '1mb' }));

app.get('/jwks.json', async (_req, res) => res.json(await client.myJwks()));
app.get('/manifest.json', (_req, res) => res.json(MANIFEST)); // defined in §6
app.post('/inbound', async (req, res) => {
    try {
        const env = await client.verifyInbound(req.body);
        // dispatch by env.turn — see §9
        res.json({ ok: true });
    } catch (e) {
        res.status(401).json({ error: (e as Error).message });
    }
});

app.listen(9001);

A NestJS controller looks the same shape — @Controller('') with @Get('jwks.json') etc.


6. Write your manifest

The manifest declares what tools you expose. Choir admins refresh it from the admin UI; the cached version drives the per-channel grants. This is what makes you actually useful.

Minimum required shape

{
  "cap_version": "0.2",
  "iss": "your.partner.io",
  "name": "Your Product",
  "tools": []
}

That's a legal manifest. But a partner with zero tools can only do say + event — fine for notifications-only partners, not enough for anything richer.

Realistic example — modeled on what Bella's manifest would look like

{
  "cap_version": "0.2",
  "iss": "bella.bosso.app",
  "name": "Bella",
  "description": "Bosso's customer + staff AI assistant — search, quotations, order ops, vendor management, refunds.",
  "vendor": {
    "name": "Bosso",
    "url": "https://bosso.app",
    "contact": "engineering@bosso.app"
  },
  "tools": [
    {
      "name": "bella.product.search",
      "title": "Search the Bosso catalog",
      "description": "Searches products by name, category, or vendor. Returns up to 20 matches with price + availability.",
      "input_schema": {
        "type": "object",
        "properties": {
          "query": { "type": "string", "minLength": 2 },
          "category": { "type": "string" },
          "vendor_id": { "type": "string", "format": "uuid" }
        },
        "required": ["query"]
      },
      "risk": "safe"
    },
    {
      "name": "bella.kb.search",
      "title": "Search Bosso's knowledge base",
      "description": "Semantic search over Bosso's KB articles (product specs, policy docs, vendor FAQs).",
      "input_schema": {
        "type": "object",
        "properties": { "query": { "type": "string", "minLength": 2 } },
        "required": ["query"]
      },
      "risk": "safe"
    },
    {
      "name": "bella.order.summary",
      "title": "Get order summary",
      "description": "Returns the customer-facing summary of an order: items, total, delivery status, payment status.",
      "input_schema": {
        "type": "object",
        "properties": { "order_id": { "type": "string" } },
        "required": ["order_id"]
      },
      "risk": "safe"
    },
    {
      "name": "bella.customer.summary",
      "title": "Get customer summary",
      "description": "Returns aggregated customer profile + lifetime stats. Surfaces PII; grant only on channels with the right audience.",
      "input_schema": {
        "type": "object",
        "properties": { "customer_id": { "type": "string" } },
        "required": ["customer_id"]
      },
      "risk": "sensitive"
    },
    {
      "name": "bella.quotation.draft",
      "title": "Draft a B2B quotation",
      "description": "Drafts a multi-line quote for a B2B customer. Generates the PDF + emails it on approval.",
      "input_schema": {
        "type": "object",
        "properties": {
          "customer_id": { "type": "string" },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "product_id": { "type": "string" },
                "quantity": { "type": "integer", "minimum": 1 }
              },
              "required": ["product_id", "quantity"]
            }
          },
          "notes": { "type": "string" }
        },
        "required": ["customer_id", "lines"]
      },
      "risk": "sensitive",
      "requires_propose": true
    },
    {
      "name": "bella.refund.execute",
      "title": "Issue a refund",
      "description": "Refunds an order. Always invoked through propose so a human signs off before money moves.",
      "input_schema": {
        "type": "object",
        "properties": {
          "order_id": { "type": "string" },
          "amount": { "type": "number", "minimum": 0.01 },
          "reason": { "type": "string" }
        },
        "required": ["order_id", "amount", "reason"]
      },
      "risk": "sensitive",
      "requires_propose": true
    }
  ],
  "subscribes_to_events": ["proposal.decided"]
}

A zpos / Xana manifest would look similar — different domain, same shape. Pick a starter subset (3–5 tools) and grow it as workspaces ask for more.

Validation rules Choir applies

When an admin clicks "Refresh Manifest", Choir validates strictly. Any failure rejects the whole manifest with a specific error message — no partial caching.

RuleFailure message looks like
cap_version must be "0.1" or "0.2"unsupported cap_version: 99.0
iss must match the iss Choir registered for youmanifest.iss 'other.partner.io' does not match registered partner iss 'bella.bosso.app'
name required, non-emptymanifest.name is required
tools must be an arraymanifest.tools must be an array
Each tool name matches ^[a-z0-9_.-]+$manifest.tools[2].name 'Bella.Search' is invalid (use lowercase, digits, ._-)
Tool names unique within manifestmanifest.tools: duplicate tool name 'bella.search'
title + description required, non-emptymanifest.tools[0].title is required
risk is 'safe' or 'sensitive'manifest.tools[0].risk must be 'safe' or 'sensitive'
input_schema is a JSON objectmanifest.tools[0].input_schema must be a JSON object

Risk levels — what they mean in practice

  • safe — reads, lookups, list endpoints, anything that can't damage state. Admins generally grant these in many channels. Calls go through directly via tool_request.
  • sensitive — writes, deletes, irreversible actions, financial moves. Admins grant per channel only after explicit thought. Direct tool_request works but UX nudges toward propose.
  • requires_propose: true — explicit signal that the tool should ONLY be invoked through propose → human approval → execution, never direct tool_request. Use for refunds, deletions, payroll, anything you'd want to look back at and ask "who signed off on this?". Choir's UI flags it visually; server-side enforcement comes in a later phase.

Renaming a tool? Don't.

name is the stable identifier admins grant against — rename it and every existing grant breaks silently. If you need a new signature, add a NEW tool with a new name and deprecate the old one. We'll add a deprecated: true flag for graceful sunsetting in a later spec rev.


7. Register with Choir

This step is a Choir-side operation (POST /cap/partners) and is staff-only. Once we have a self-serve partner portal it'll move; for now, send a Choir maintainer this:

{
  "iss": "your.partner.io",
  "name": "Your Product",
  "description": "Short pitch — one or two sentences.",
  "manifest_url": "https://your.partner.io/manifest.json",
  "jwks_url": "https://your.partner.io/jwks.json",
  "inbound_url": "https://your.partner.io/inbound"
}

A Choir staff member runs POST /cap/partners with that body. You'll get the partner UUID back.

For local-dev integration, your URLs can be http://localhost:9001/... and a Choir running on localhost:4000 can reach them. If you're on different hosts, you'll need to expose the partner via ngrok / cloudflare tunnel / similar so Choir can fetch your manifest + JWKS.

After registration, each workspace admin separately:

  1. Approves the connectionPOST /cap/workspaces/<wid>/connections with {partner_id, approved_scopes}.
  2. Grants channel scopesPOST /cap/connections/<id>/channel-scopes with {channel_id} per channel.
  3. Refreshes your manifestPOST /cap/partners/<id>/refresh-manifest (also reachable from /admin/cap/partners/<id> in the admin UI).
  4. Grants tools per channelPOST /cap/connections/<id>/tool-grants with {tool_name, channel_id}.

Until step 4, you can send say + event envelopes into granted channels, but every inbound tool_request will reject with tool_not_granted.


8. Send your first envelope

# Python
client.send_say(workspace="acme", channel="ops", body="Hello from Bella.")
// TypeScript
await client.sendSay({ workspace: 'acme', channel: 'ops', body: 'Hello from Bella.' });

If you get a 200 response with {"ok": true, "message_id": "msg_..."}, the envelope was accepted and a message appears in #ops for everyone watching that channel.

Common rejections at this stage:

HTTPReasonWhat to check
401kid_not_found / bad_signatureChoir hasn't fetched your JWKS yet, or your KID doesn't match the JWK you published
403no active connection for issuer 'X' in workspace 'Y'Admin hasn't approved the connection
403connection not scoped to channel 'Z'Admin hasn't granted the channel scope
404workspace not found / channel not foundTypo in the aud field; check spelling of slugs

9. Handle inbound envelopes from Choir

Choir POSTs signed envelopes to your /inbound when:

  • a message lands in a channel you're scoped to (you'll see it as turn: 'say')
  • a Choir-side event you subscribed to fires (e.g. proposal.decided)
  • a workspace member invokes one of your tools (turn: 'tool_request') — once tool grants are wired
  • Choir's tool calls have results to return to you (turn: 'tool_result') — for Direction-B calls you initiate

Skeleton

@app.post("/inbound")
async def inbound(request: Request):
    body = await request.json()
    try:
        env = client.verify_inbound(body)
    except ValueError as e:
        raise HTTPException(401, str(e))

    turn = env["turn"]
    payload = env["payload"]
    workspace, channel = env["aud"].split("/", 1)

    if turn == "say":
        # A message landed in a channel you're watching.
        body = payload["body"]
        # Real partners might update their state, notify a teammate, etc.

    elif turn == "event":
        event_type = payload["event_type"]
        attrs = payload.get("attrs", {})
        if event_type == "proposal.decided":
            # Choir is telling you the human decided on a proposal you sent.
            decision = attrs["decision"]               # 'approved' or 'rejected'
            proposal_id = attrs["proposal_id"]         # YOUR proposal id
            note = attrs.get("note")                   # optional decider's note
            if decision == "approved":
                handle_approved_action(proposal_id, note)
            else:
                handle_rejected_action(proposal_id, note)

    elif turn == "tool_request":
        # Workspace member is asking you to run a tool.
        call_id = payload["call_id"]
        tool_name = payload["tool_name"]
        args = payload.get("args", {})
        # Phase 2 (optional) — identity of the human Choir user whose
        # action triggered the call. See §"Caller-based RBAC" below.
        caller = payload.get("caller")  # dict or None
        try:
            # OPTIONAL: gate sensitive tools by the caller's workspace
            # role. Falls back to "trust Choir's grants" when caller
            # is absent (Phase 1 partners, preview turns, future
            # scheduled-job runners).
            if caller and tool_name in SENSITIVE_TOOLS:
                if caller.get("role") not in ("admin", "manager"):
                    client.send_tool_result_error(
                        workspace=workspace, channel=channel,
                        call_id=call_id, code="forbidden_for_role",
                        message="this tool requires admin or manager role",
                    )
                    return {"ok": True}

            result = run_my_tool(tool_name, args)
            client.send_tool_result_ok(
                workspace=workspace, channel=channel,
                call_id=call_id, result=result,
            )
        except NotFoundError as e:
            client.send_tool_result_error(
                workspace=workspace, channel=channel,
                call_id=call_id, code="not_found", message=str(e),
            )
        except Exception as e:
            client.send_tool_result_error(
                workspace=workspace, channel=channel,
                call_id=call_id, code="internal", message=str(e),
            )

    elif turn == "tool_result":
        # Response to a tool_request YOU sent. Correlate by call_id.
        call_id = payload["call_id"]
        if payload["status"] == "ok":
            handle_my_pending_call(call_id, payload["result"])
        else:
            handle_my_pending_call_error(call_id, payload["error"])

    return {"ok": True}

Critical: always verify before trusting

verify_inbound is the security boundary. Anyone with network access to your /inbound URL can POST anything. Only signature verification confirms the envelope came from a workspace you trust. Don't dispatch on env["turn"] until verification passes.

Caller-based RBAC (Phase 2 of the permission model)

tool_request envelopes from Choir include an optional caller block in the payload:

{
  "type": "tool_request",
  "tool_name": "issue_refund",
  "args": { "order_id": "ord_123", "amount_cents": 4200 },
  "caller": {
    "user_id": "u_01J5XK4P...",
    "role": "admin",
    "is_staff": false
  }
}

This lets you compose your own RBAC on top of Choir's per-channel tool grants:

LayerWho decidesWhat it controls
Choir grants (Phase 1)Workspace admin via Choir UIWhich of your tools are reachable from which channel
Caller RBAC (Phase 2)You, in /inboundWhat those tools return for THIS specific human

What to do with caller:

  • Present + role in (admin, manager, member, guest) — make your decision. The role is the user's workspace role at envelope-issue time. Don't cache role decisions past the envelope's exp (5 min default) — role changes take effect on the next envelope.
  • Present + is_staff: true — issuer-platform staff (Choir staff in Choir-issued envelopes). Use in addition to role if you gate internal-tier data.
  • Absent — Phase 1 partner OR no human caller (preview mode, future scheduled-job runners). Fall back to trusting Choir's grants — don't reject.

The field is strictly additive on the wire. If you don't read it, you keep working in Phase 1. If you do, you get Phase 2.


10. The propose pattern — human-in-loop for sensitive actions

For anything you'd want a human to sign off on before you do it, send a propose envelope. Choir renders it as an inline approve/reject card in the channel. When a human decides, Choir POSTs a proposal.decided event back to your /inbound.

# Send the proposal
client.send_propose(
    workspace="acme", channel="finance-approvals",
    proposal_id="bella-refund-4471",        # YOUR id, stable across retries
    title="Refund order #4471",
    description="$234 to customer X — defective product reported via WhatsApp",
    action={
        "tool_name": "bella.refund.execute",
        "args": {"order_id": "4471", "amount": 234, "reason": "defective"},
    },
    expires_in_sec=24 * 3600,
)

Wait for the decision event to land on your /inbound:

if event_type == "proposal.decided":
    if attrs["proposal_id"] == "bella-refund-4471":
        if attrs["decision"] == "approved":
            # Now execute the refund FOR REAL.
            run_refund(order_id="4471", amount=234)
        else:
            # Human rejected. Maybe notify the customer.
            notify_customer_refund_denied(...)

Why this is the high-value pattern

Without propose, you have two bad options for sensitive actions: (a) just do them and hope nobody complains, or (b) require human approval through your own UI, scattered across your dashboards. With propose, the human review happens in the channel where the relevant team already lives, with full audit, threaded discussion if needed, and the decision routed back to you as a signed event.

Idempotency

You're allowed (and encouraged) to retry sending the same proposal envelope if Choir was momentarily unreachable. Choir deduplicates on (connection_id, proposal_external_id) — the same proposal_id from the same partner never creates two proposals. Use a stable id per business operation (refund-<order_id>, not uuid4()).


11. Tool grants — what admins control

Each tool you advertise can be granted per channel by a workspace admin. The grant model is intentionally explicit:

For each (partner, channel, tool) the admin allows → one grant row.
Anything not granted is denied.

Workspace admins manage grants from /admin/cap/partners/<your-id>:

  • They see your manifest (refreshable on demand).
  • For each connection (per workspace using your partner), they see existing grants + a dropdown to add new ones.
  • They can revoke any grant at any time. Future tool_request envelopes for that (channel, tool) start rejecting immediately.

What happens on inbound when there's no grant?

Partner → Choir: tool_request { call_id, tool_name, args }
Choir   → Partner (echo): tool_result {
    call_id: <echoed>,
    status: "error",
    error: {
        code: "tool_not_granted",
        message: "Tool 'bella.refund.execute' is not granted to bella.bosso.app on this channel. An admin must grant it first."
    }
}

You don't have to handle this — the error message tells you exactly what's wrong. Show it to your operator or log it for the admin to action.

tool_not_advertised vs tool_not_granted

  • tool_not_granted — admin hasn't approved this tool on this channel. Action item is on the admin.
  • tool_not_advertised — admin HAS granted the tool, but the tool name doesn't match a handler. Typically a typo in the grant; double-check the admin granted the exact tool name from your manifest.

12. Testing the round-trip

The fastest way to confirm your integration works end-to-end:

1. Run the sample partner locally first

Even if you're not going to ship the sample as-is, run it for 10 minutes against your local Choir. It exercises every code path.

# Python
cd <CHOIR_REPO>/cap-sdks/python
pip install -e .[fastapi]
CHOIR_BASE_URL=http://localhost:4000 python examples/sample_partner.py

# Or TypeScript
cd <CHOIR_REPO>/cap-sdks/typescript
npm install
CHOIR_BASE_URL=http://localhost:4000 npx ts-node examples/sample-partner.ts

Have a Choir staff member register sample.partner.local with manifest_url=http://localhost:9001/manifest.json etc, then approve the connection for a test workspace.

2. Smoke test in three calls

# 1. Post a say into a channel
curl -X POST http://localhost:9001/demo/say \
  -H "Content-Type: application/json" \
  -d '{"workspace": "my-workspace", "channel": "test", "body": "Hello from sample"}'

# 2. Send a propose
curl -X POST http://localhost:9001/demo/propose \
  -H "Content-Type: application/json" \
  -d '{"workspace": "my-workspace", "channel": "test",
       "title": "Test proposal",
       "description": "Click Approve in the channel",
       "action": {"tool_name": "sample.echo", "args": {"text": "hi"}}}'

# 3. In Choir, approve the proposal. Watch your sample partner's stdout —
#    you should see a `proposal.decided` event arrive on /inbound.

3. Then swap your real implementation

Replace the sample with your real partner code. The shapes are exactly the same; only your tool dispatch + your event reactions change.

Local manifest-fetch checklist

If Choir can't reach your /manifest.json because you're behind a firewall:

  • ngrok http 9001 → use the public ngrok URL as manifest_url
  • Or cloudflared tunnel --url http://localhost:9001 for a Cloudflare quick tunnel

JWKS + /inbound need the same public reachability.


13. FAQ + troubleshooting

My SDK install fails — can I get the latest version?

pip install -U cap-partner (Python) or npm update @choirhq/cap-partner (TypeScript). If you originally installed from a local path while iterating, switch to the registry install above for production.

What does "ES256" mean — can I use RSA / Ed25519 instead?

ES256 = ECDSA over the P-256 curve with SHA-256. It's what CAP requires at v0.2. We picked it because (a) keys + signatures are small, (b) it's natively supported by every JOSE library and most secrets managers, (c) it's what Choir's own envelope signer uses. We'd consider EdDSA in a future spec rev.

How do I rotate my signing key?

  1. Generate a new keypair, get a new kid.
  2. Update /jwks.json to publish BOTH the old key (still serving for in-flight envelopes from Choir verifying past signatures) and the new key.
  3. Flip your client's kid + privateKeyPem to the new pair.
  4. After a grace period (5 min is plenty — envelope max age is 5 min), drop the old key from your JWKS.

Choir's SDK refreshes its JWKS cache on signature failure, so rotation is transparent on Choir's side. Your inbound verification handles the same way via the SDK's verify_inbound.

Can I send envelopes to multiple workspaces at once?

You can't broadcast — each envelope has a single aud: "workspace/channel". But you can send to many in parallel; the SDK is thread/async-safe and the client maintains a per-workspace JWKS cache.

How do I subscribe to an event type that isn't in subscribes_to_events yet?

For now, list it in your manifest and Choir's dispatcher will include you for matching events. Today the dispatcher fans out every message_saved in a channel to every scoped connection regardless of subscribes_to_events (it's a v0.3 filter; declared but not yet enforced). So even partners that don't declare a subscription will see channel messages.

What's the rate limit?

No per-partner rate limit at v0.2. Choir's dispatcher retries up to 3 times with backoff, so even a flaky partner doesn't break the channel. We'll add limits when we observe abuse.

verify_inbound is failing on what looks like a valid envelope. What now?

In order:

  1. Confirm the JWKS Choir fetched is current. Curl your /jwks.json from a machine that can reach it the way Choir does. If it 404s or returns stale keys, fix that first.
  2. Confirm your system clock is sane. Envelopes have iat + exp; if your clock is off by more than ~60s the verifier rejects them as "in the future" or "expired".
  3. Look at the failure message — the SDK raises with specifics: kid_not_found, bad_signature, expired, unknown_issuer. Each one points at a different cause.
  4. If you suspect a JCS / canonicalization bug, our cross-implementation test pins the canonical form for a known input. Check the SDK's tests/test_envelope.py::test_canonical_form_matches_typescript_reference — your custom implementation (if you have one) should produce the same byte output for that input.

Where do I file a bug?

GitHub: https://github.com/Mukopaje/choir/issues. Tag it [cap-sdk] (Python or TS) so it routes to the right person.


14. What's coming

Soon (Choir-internal — minor partner-facing impact)

  • Self-serve partner portal — register your partner + manage your manifest without staff in the loop. Today the registration step (§7) requires a Choir staff member.

Shipped (recent — heads up)

  • SDKs published to registriespip install cap-partner and npm install @choirhq/cap-partner are live. Local-path install (§3 contributor section) is now only for SDK contributors, not regular partner integrators.
  • Direction-B tool calls — Choir Voices invoking your tools from inside their tool palette. Live since v0.2. Your code doesn't change; you'll just see tool_request envelopes arrive during real Voice turns, not only when an admin triggers a test. Plan for higher + spikier traffic; keep call_id correlation tight. See KB article cap-voice-tools-from-partners for the admin's-eye view.
  • Phase 2 caller identitytool_request envelopes now include an optional caller block (user_id + role + is_staff) so you can perform partner-side RBAC in addition to Choir's per-channel grants. See §9 "Caller-based RBAC".

Soon (protocol-level — partner code may need a small update)

  • Multi-instance Choir support for outgoing tool calls. Today, a Choir-initiated tool_request whose tool_result lands on a different Choir instance (behind a load balancer) will time out. Single-instance Choir deployments unaffected. Redis pub/sub or DB-poll variant on the roadmap.
  • requires_propose server-side enforcement — today it's an admin-UI hint; we'll block direct tool_request for requires_propose: true tools at the inbound handler in a later phase.
  • Cross-workspace tool invocation — today envelopes route to one workspace/channel. A future version may let Voices in workspace A invoke tools in workspace B (with both workspaces' admin consent).

Maybe (depends on demand)

  • Additional turn typesask (synchronous-ish reply), subscribe (long-poll fan-out), transfer (handoff conversation to another partner), escalate (force human attention). Reserved in the spec; not implemented.
  • WebSocket / SSE channel for high-frequency events — today everything is per-envelope HTTP POST. If we see partners getting throttled by that, we'll add a streaming variant.

If any of the above is blocking you, file an issue and tell us.


Quick reference card

Three endpoints you serve:
    GET  /jwks.json       → client.my_jwks()
    GET  /manifest.json   → YOUR_MANIFEST dict
    POST /inbound         → verify_inbound(body), then dispatch by env["turn"]

Things you send to Choir (POST {choir_base_url}/cap/v1/inbound):
    send_say                — a message
    send_event              — a structured notification
    send_propose            — action awaiting human approval
    send_tool_request       — ask a Choir-side tool to run
    send_tool_result_ok     — respond to an inbound tool_request (success)
    send_tool_result_error  — respond to an inbound tool_request (failure)

Inbound turn types you'll receive:
    say            — a message landed in a scoped channel
    event          — workspace-side event (e.g. proposal.decided)
    tool_request   — Choir is asking you to run one of YOUR tools
    tool_result    — response to a tool_request YOU sent

Manifest essentials:
    cap_version: "0.2"
    iss: <matches your registered iss>
    name: <your product name>
    tools: [{name, title, description, input_schema, risk, requires_propose?}, ...]

Tool names: lowercase + digits + . _ - only, unique within manifest.
Risk: 'safe' or 'sensitive'.
Sensitive actions: prefer propose, not direct tool_request.

Welcome aboard — we'll see your envelopes on the wire soon.