@choirhq/cap-partner
v0.2.1
Published
TypeScript/Node SDK for the Choir CAP (Channel-Aware Partner) protocol — build partner integrations that participate in Choir channels as first-class members.
Downloads
139
Maintainers
Readme
@choirhq/cap-partner (TypeScript / Node SDK)
Drop-in SDK for any Node service that wants to integrate with Choir over the CAP (Channel-Aware Partner) protocol v0.2. NestJS-native via the underlying fetch + Web Crypto; works equally well in Express, Fastify, raw scripts.
Functionally identical to the Python SDK — same wire format, same APIs, same tests. Pick whichever fits your stack.
Install
npm install @choirhq/cap-partner
# or: pnpm add @choirhq/cap-partner / yarn add @choirhq/cap-partnerRequires Node 18+ (built-in fetch + crypto.subtle). Only runtime dependency: jose.
Quick start
1. Generate a signing key (once)
import { generateEs256Keypair } from '@choirhq/cap-partner';
const { pem } = await generateEs256Keypair();
await fs.writeFile('partner.key', pem); // store in your secrets managerGenerate once and load from your secrets manager at boot. 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 from /admin/cap/partners/<id>.
3. Wire the client
import { CapPartnerClient } from '@choirhq/cap-partner';
const client = new CapPartnerClient({
iss: 'my.partner.io',
kid: '2026-q2',
privateKeyPem: process.env.CAP_PRIVATE_KEY_PEM!,
choirBaseUrl: 'https://choir.example.com',
});
// Post a message
await client.sendSay({
workspace: 'acme', channel: 'ops',
body: 'Hello from my partner.',
});
// Post an event
await client.sendEvent({
workspace: 'acme', channel: 'alerts',
eventType: 'order.delivered',
attrs: { order_id: '4471', carrier: 'DHL' },
});
// Propose an action a human in the channel must approve
await client.sendPropose({
workspace: 'acme', channel: 'finance-approvals',
proposalId: '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, Choir POSTs a proposal.decided event envelope to your /inbound.
4. Serve the three required endpoints
See examples/sample-partner.ts for a complete Express implementation — copy + adapt.
GET /jwks.json— returnawait client.myJwks()GET /manifest.json— return your manifest objectPOST /inbound—await client.verifyInbound(req.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.
const 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: '[email protected]',
},
tools: [
{
name: 'my.search',
title: 'Search my catalog',
description: 'Searches my product catalog by name or category.',
input_schema: {
type: 'object',
properties: { q: { type: 'string' } },
required: ['q'],
},
risk: 'safe',
},
{
name: 'my.refund.execute',
title: 'Execute refund',
description: 'Refunds a transaction. Invoke 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,
},
],
subscribes_to_events: ['proposal.decided'],
};Validation rules (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
app.post('/inbound', async (req, res) => {
try {
const env = await client.verifyInbound(req.body);
// dispatch by env.turn — see examples/sample-partner.ts
res.json({ ok: true });
} catch (e) {
res.status(401).json({ error: e.message });
}
});verifyInbound:
- fetches Choir's JWKS at
{choirBaseUrl}/cap/{workspaceSlug}/.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, refreshes the JWKS once (handles key rotation) before failing.
Running the sample partner
cd cap-sdks/typescript
npm install
CHOIR_BASE_URL=http://localhost:4000 npx ts-node examples/sample-partner.tsIf CAP_PRIVATE_KEY_PEM is unset, it generates an ephemeral keypair on boot with a clear warning. Production must supply a stable key.
Running tests
npm install
npm testLicense
MIT.
