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
- What CAP is, in one minute
- Pick your stack
- Install the SDK locally — Python + TypeScript
- Configure your environment
- Stand up your three endpoints
- Write your manifest — includes a realistic example
- Register with Choir
- Send your first envelope
- Handle inbound envelopes from Choir
- The propose pattern — human-in-loop for sensitive actions
- Tool grants — what admins control
- Testing the round-trip
- FAQ + troubleshooting
- 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 is | Use | Reference sample |
|---|---|---|
| Python (FastAPI, Django, Flask, scripts) | cap-partner | examples/sample_partner.py |
| TypeScript / Node (Express, NestJS, Fastify, scripts) | @choirhq/cap-partner | examples/sample-partner.ts |
Both SDKs:
- speak the same wire protocol (an envelope signed in one verifies in the other)
- have a
verify_inbound/verifyInboundhelper 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:
| Endpoint | Verb | Returns | Auth |
|---|---|---|---|
/jwks.json | GET | Your public JWKS (so Choir can verify your envelopes) | None — public |
/manifest.json | GET | What tools you advertise + vendor metadata | None — public |
/inbound | POST | Receives signed envelopes from Choir | Verified 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.
| Rule | Failure 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 you | manifest.iss 'other.partner.io' does not match registered partner iss 'bella.bosso.app' |
name required, non-empty | manifest.name is required |
tools must be an array | manifest.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 manifest | manifest.tools: duplicate tool name 'bella.search' |
title + description required, non-empty | manifest.tools[0].title is required |
risk is 'safe' or 'sensitive' | manifest.tools[0].risk must be 'safe' or 'sensitive' |
input_schema is a JSON object | manifest.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 viatool_request.sensitive— writes, deletes, irreversible actions, financial moves. Admins grant per channel only after explicit thought. Directtool_requestworks but UX nudges toward propose.requires_propose: true— explicit signal that the tool should ONLY be invoked through propose → human approval → execution, never directtool_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:
- Approves the connection —
POST /cap/workspaces/<wid>/connectionswith{partner_id, approved_scopes}. - Grants channel scopes —
POST /cap/connections/<id>/channel-scopeswith{channel_id}per channel. - Refreshes your manifest —
POST /cap/partners/<id>/refresh-manifest(also reachable from/admin/cap/partners/<id>in the admin UI). - Grants tools per channel —
POST /cap/connections/<id>/tool-grantswith{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:
| HTTP | Reason | What to check |
|---|---|---|
| 401 | kid_not_found / bad_signature | Choir hasn't fetched your JWKS yet, or your KID doesn't match the JWK you published |
| 403 | no active connection for issuer 'X' in workspace 'Y' | Admin hasn't approved the connection |
| 403 | connection not scoped to channel 'Z' | Admin hasn't granted the channel scope |
| 404 | workspace not found / channel not found | Typo 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:
| Layer | Who decides | What it controls |
|---|---|---|
| Choir grants (Phase 1) | Workspace admin via Choir UI | Which of your tools are reachable from which channel |
| Caller RBAC (Phase 2) | You, in /inbound | What 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'sexp(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 toroleif 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_requestenvelopes 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 asmanifest_url- Or
cloudflared tunnel --url http://localhost:9001for 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?
- Generate a new keypair, get a new kid.
- Update
/jwks.jsonto publish BOTH the old key (still serving for in-flight envelopes from Choir verifying past signatures) and the new key. - Flip your client's
kid+privateKeyPemto the new pair. - 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:
- Confirm the JWKS Choir fetched is current. Curl your
/jwks.jsonfrom a machine that can reach it the way Choir does. If it 404s or returns stale keys, fix that first. - 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". - 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. - 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 registries —
pip install cap-partnerandnpm install @choirhq/cap-partnerare 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_requestenvelopes arrive during real Voice turns, not only when an admin triggers a test. Plan for higher + spikier traffic; keepcall_idcorrelation tight. See KB articlecap-voice-tools-from-partnersfor the admin's-eye view. - Phase 2 caller identity —
tool_requestenvelopes now include an optionalcallerblock (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_requestwhosetool_resultlands 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_proposeserver-side enforcement — today it's an admin-UI hint; we'll block directtool_requestforrequires_propose: truetools 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 types —
ask(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.