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
- Commerce vocabulary — the
product.*events - Fulfillment vocabulary — the
order.status_changed+fulfillment.tracking_eventevents - Commerce order lifecycle — the
order.*lifecycle events - Importing already-paid orders —
commerce.order.imported - Async result handling for
tool_request— the correlator pattern - 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.
Pick your integration shape BEFORE you touch the manifest
CAP was built for two very different kinds of partner. Deciding which one you are up front avoids weeks of confused integration.
| Shape | You are… | Voices in Choir see… | Your inbound handles… |
|---|---|---|---|
Structured tools (agent_mode='tools', default) | A system with deterministic operations — a courier that returns a tracking number, a payment gateway that returns a receipt id, a warehouse that returns a stock count | Your advertised manifest tools as callable functions with typed args + returns | tool_request envelopes → run the tool → sign + POST a tool_result back |
Conversational agent (agent_mode='conversational') | Your whole product is an AI that already owns a domain — Xana for UZARA POS, an AI copilot, a support agent that speaks in natural language | Your partner ONLY as a chat-scoped conversation surface. Manifest tools stay hidden even if you declare them | say envelopes only → your AI reads the body, uses its OWN internal tools, replies with a signed say |
How to choose:
- If a caller needs
{ "total": 4500 }to plug into subsequent logic → structured tools. - If a caller wants to ask "how are things going with tenant X?" and get a paragraph back → conversational agent.
- If you have both, register as structured tools and reserve
conversationalfor partners that would otherwise expose a dozen thin wrapper tools around their AI.
Consequences:
- Structured tools: write a manifest with your tool list (
name,title,description,input_schema,risk). Stand up handlers per tool. Each Choir voice can call them individually. Documented in §5 through §7 below. - Conversational agent: register with
"tools": [](empty array) in your manifest — justiss,name,jwks_url,inbound_url. Your inbound handles ONE turn type —say— and passes the body straight to your AI. No per-tool schema, notool_requesthandling, no manifest to keep in sync when your AI gains capabilities.
An admin flips the mode via the connection card in Choir (agent_mode: 'tools' | 'conversational') or via PATCH /cap/connections/:id/agent-mode. Flippable without re-integrating.
Everything else in this document — signing, JWKS, discovery, envelopes, propose flows, DMs — applies to both shapes.
Discovery — read this before you code anything
Choir publishes everything you need to bootstrap at ONE URL:
GET https://api.choirworkspace.com/.well-known/cap-configuration
Response tells you:
- Where to POST inbound envelopes (
inbound_url) - Where to fetch a workspace's JWKS (
jwks_url_template—{workspace_slug}is a placeholder filled from each envelope'sissfield) - Signature algorithm, envelope canonicalization, envelope types
- Which extra
X-CAP-*headers Choir sends on outbound POSTs - Retry policy
You never need to guess a Choir URL. Fetch the config doc, cache it, and use its templates.
And for signature verification: every JWS Choir sends carries a jku (JWK Set URL) in the protected header (RFC 7515 §4.1.2), so you don't have to construct the JWKS URL yourself at all — read jku off the JWS header, fetch it (with caching), verify. Same URL is mirrored in the X-CAP-JWKS-URL header on the outbound POST for callers that route by headers.
Once registered, you can look up your own configuration:
GET https://api.choirworkspace.com/cap/partners/by-iss/<your-iss>/registration
Returns your iss, name, status, inbound_url (the URL Choir has on file for you), manifest_url, jwks_url. Public — no auth. Use it to confirm what Choir thinks your endpoints are before opening a ticket.
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/<workspace_uuid>/connectionswith{partner_id, approved_scopes}. - Grants channel scopes —
POST /cap/connections/<connection_uuid>/channel-scopeswith{channel_id}per channel. - Refreshes your manifest —
POST /cap/partners/<partner_uuid>/refresh-manifest(also reachable from/admin/cap/partners/<partner_uuid>in the admin UI). - Grants tools per channel —
POST /cap/connections/<connection_uuid>/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.
Workspace identifier — slug vs UUID (read this once)
Choir addresses workspaces two different ways depending on the surface:
- Admin setup endpoints (steps 1–4 above) take the workspace UUID in the path:
<workspace_uuid>. The admin grabs this from the admin UI or from a workspace-info response.- Partner envelopes (the
audclaim, theworkspace=argument toclient.send_say/client.sendSay, and the JWKS path/cap/<slug>/.well-known/jwks.json) use the workspace slug — a human-readable identifier likeacmeorbosso-africa-inc.Putting a UUID where the slug belongs returns a 404 from
/cap/v1/inbound("workspace not found") because the inbound handler doesworkspaces.findOne({ where: { slug: aud.workspaceSlug } }). The SDK throws a clear local error when theworkspace=argument matches a UUID shape, but if you're rolling your own envelope builder you'll see the 404 only at the round-trip. Ask the workspace admin for the slug — it's in the workspace URL and in the CAP integration handoff payload.
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.
Which turn type should I use? (say vs event vs propose)
The CAP protocol gives you three outbound turn types and they render very differently in the channel. This matters a lot for what humans actually see. A common mistake is using event for things that should be say, which makes them look like grey log noise instead of real channel activity.
| Turn | When to use | How it renders in the channel |
|---|---|---|
say | Anything a human reading the channel would want to read like a message — order placed, customer reply, status update they care about. | Full chat message in the timeline, indistinguishable from a human post. Triggers @-mention notifications, can be replied to in thread, voices can react. |
event | Structured machine signals — heartbeats, internal state transitions, things that are interesting to AI/tools but not to a scrolling human. | A thin grey one-liner: [<your-iss>] <event_type> — <key>: <value> · <key>: <value>. If you include attrs.summary, that takes over as the body verbatim. |
propose | Anything that needs human approval before it happens — refunds, status overrides, sensitive writes. See §10. | An interactive approve/reject card. Stays in the channel as historical record after the human decides. |
Rule of thumb: if a non-technical channel member would say "wait, what was that?" when they see it scroll by, it's a say, not an event. Events are basically structured logs the AI can read; humans glance past them.
Make events readable when you do use them. The body Choir renders is built from attrs:
# Bad — humans see "[bella-dev.bosso.io] bella.order.created (no details)"
client.send_event(
workspace="acme", channel="ops",
event_type="bella.order.created", attrs={},
)
# OK — humans see "[bella-dev.bosso.io] bella.order.created — order_id: 4471 · customer: Alice · amount: 850"
client.send_event(
workspace="acme", channel="ops",
event_type="bella.order.created",
attrs={"order_id": "4471", "customer": "Alice", "amount": 850},
)
# Best — humans see "[bella-dev.bosso.io] bella.order.created — Order #4471 for Alice — K850 (COD)"
client.send_event(
workspace="acme", channel="ops",
event_type="bella.order.created",
attrs={
"summary": "Order #4471 for Alice — K850 (COD)",
# full structured data still preserved on the message payload for AI consumption
"order_id": "4471", "customer": "Alice", "amount": 850, "payment": "COD",
},
)
Rendering rules: attrs.summary (string) wins and is used verbatim. Otherwise Choir stitches up to 5 scalar attrs as key: value · key: value. Arrays and nested objects are skipped in the body but stay on the message payload for AI/tool consumption. Long values are truncated at 80 chars in the rendered body; the full value remains on the payload.
If you currently use event for everything, consider auditing: order-created, customer-reply, status-changed, ticket-opened — those are almost always say material. Heartbeats, sync-complete pings, audit-trail breadcrumbs are real event material.
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. Most common cause: a workspace UUID was passed where a slug belongs. See the slug-vs-UUID note in §7. |
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
Correlating a reply back to the message you sent
Every inbound say payload carries a parent_id field. If the human who typed the message did so as a threaded reply to a message YOU sent, parent_id is the choir_message_id of that original message. If they posted at the top level of the channel, parent_id is null.
This is the field to use for the "I sent a question, now correlate the reply back to my pending request" pattern (analogous to WhatsApp's context.id):
if turn == "say":
body = payload["body"]
parent_id = payload.get("parent_id") # str or None
if parent_id:
# Human replied in-thread to a message we previously sent.
# Look up the pending request by the original message id.
pending = pending_requests.get_by_choir_message_id(parent_id)
if pending:
pending.resolve(body) # your business logic
return {"ok": True}
# Top-level or unrelated reply — handle as a free-standing message.
Payload shape of an inbound say (v0.1):
{
"body": "quote is K120 for zone 3",
"choir_message_id": "msg_reply_id",
"parent_id": "msg_original_bella_request_id",
"author": { "kind": "human", "user_id": "u_..." }
}
parent_id is always present on the wire; its value is null when the message isn't a reply. Don't assume presence-means-reply.
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. Commerce vocabulary — the product.* events
The events in this section are Choir's first domain-specific event vocabulary — a small set of well-known event_type strings the Commerce module recognizes and routes into the workspace catalog. Sending them is optional; you'd send them if your product is a shop backend (Bella, an in-house merchant BOB, any custom storefront) and you want Choir to know about your catalog so Sela can ground her replies on real prices and stock.
Unlike partner-defined events (bella.order.created, downbeat.meeting.ended) which land as system messages and stop there, these events flow into the catalog table via CommerceCapEventsService. Nothing else about the CAP protocol changes — same signed envelopes, same event turn, same channel scoping.
Events
event_type | Payload shape | When to emit |
|---|---|---|
product.created | ProductAttrs (full record) | New product row in your DB |
product.updated | ProductAttrs (full record) | Any edit — name, price, image, tags, stock, etc. |
product.deleted | { external_ref } only | Removed / unpublished (Choir archives; order history keeps the row) |
stock.changed | { external_ref, stock, variant_external_ref? } | Inventory-only tick — cheaper than a full product.updated |
commerce.catalog.snapshot_request | Choir → you; see §14.3 below | You RECEIVE this and respond with N product.created events |
product.created and product.updated are semantically identical to Choir — both upsert on (workspace_id, source, external_ref). The two exist so your side can keep its own new-vs-edit distinction if you want to; Choir doesn't require you to know which is which.
14.1. ProductAttrs shape
Same in both SDKs — this is the payload for product.created / product.updated:
external_ref string REQUIRED — your stable ID (SKU, primary key, whatever)
name string REQUIRED — display name Sela uses in replies
description string? free text; HTML acceptable, Choir strips it for grounding
price number? decimal; null = "quote on request"
currency string? ISO-4217 ("ZMW", "USD", "KES", "NGN")
sku string?
stock number? units on hand (product-level; see variants below)
tags string[] normalized lowercase server-side
image_url string?
status 'active' | 'archived' default 'active'
metadata object? passes through to the catalog row — bundle_items,
pricing_tiers, your internal category ids, whatever
14.2. Emitting from your SDK
Python:
from cap_partner import (
CapPartnerClient, CapPartnerConfig,
ProductAttrs, emit_product_created, emit_stock_changed,
)
client = CapPartnerClient(CapPartnerConfig(
iss="bella-dev.bosso.io",
kid="2026-q3",
private_key_pem=open("partner.key").read(),
choir_base_url="https://api.choirworkspace.com",
))
# On product create in your DB:
emit_product_created(client,
workspace="<workspace-id>", channel="<any scoped channel id>",
attrs=ProductAttrs(
external_ref="sku-1234",
name="Roofing sheet · IBR 0.5mm",
price=380.00, currency="ZMW", stock=240,
tags=["roofing", "hardware"],
),
)
# On stock-only change:
emit_stock_changed(client,
workspace="<workspace-id>", channel="<any scoped channel id>",
external_ref="sku-1234", stock=238,
)
TypeScript mirrors this shape — emitProductCreated, emitStockChanged, ProductAttrs, etc. from @choirhq/cap-partner.
14.3. The snapshot handshake — how initial seed works
Choir doesn't call back into your API to pull your catalog. Instead, the moment a workspace receives its first commerce inbound (a customer message on WhatsApp/IG/etc.) AND has no catalog rows yet AND has an approved CAP connection to you, Choir emits ONE event to you:
event_type: commerce.catalog.snapshot_request
attrs: { reason: 'initial_seed' | 'reconciliation', requested_at: ISO8601 }
You respond by iterating your catalog and emitting one product.created per product. Nothing else. Once the first row lands in Choir, the empty-catalog guard flips and Choir never re-asks unless a merchant explicitly clicks "Ask partner to resend catalog" in the Connections UI (which fires the same event with reason='reconciliation').
Response pattern — Python:
from cap_partner import (
COMMERCE_CATALOG_SNAPSHOT_REQUEST,
respond_to_snapshot, ProductAttrs,
)
# In your existing CAP inbound handler:
def handle_event(envelope, client):
if envelope["payload"]["event_type"] == COMMERCE_CATALOG_SNAPSHOT_REQUEST:
workspace_slug, channel_slug = envelope["aud"].split("/", 1)
# respond_to_snapshot iterates + emits product.created per row.
# One bad row logs to stderr; doesn't abort the batch.
n = respond_to_snapshot(client,
workspace=workspace_slug, channel=channel_slug,
products=(ProductAttrs(
external_ref=str(row.id),
name=row.name,
price=float(row.price) if row.price else None,
currency=row.currency,
stock=row.stock,
tags=row.tags,
) for row in bella.products.iterate()),
)
print(f"snapshot: emitted {n} products")
TypeScript version uses respondToSnapshot from @choirhq/cap-partner, which accepts a sync or async iterable.
14.4. Idempotency + the source label
Every product event is idempotent by (workspace_id, source, external_ref). Choir sets the source to cap:<your-iss> automatically — for a partner with iss="bella-dev.bosso.io" that's source="cap:bella-dev.bosso.io".
Consequences:
- Replaying
product.createdon the sameexternal_refis safe — Choir upserts. - Emitting
product.updatedafterproduct.createdfor the sameexternal_refbehaves identically (same upsert). - A snapshot response after a live catalog has already been syncing is safe — every row already there just gets a timestamp bump.
- DO NOT vary the
external_reffor the same product across events. Choir keys on it; changing it creates a duplicate.
14.5. Deferred (not part of the contract yet)
- Variant-level stock:
stock.changedaccepts avariant_external_reffield but Choir currently warn+skips those. Slice-16-timeframe follow-up; consistent with the WooCommerce + Shopify adapters which also defer variants at Choir's catalog layer today. Emitting the field now is safe (skipped, not rejected) and will start working once variants land. order.*events — this vocabulary is products only.order.paid,order.shipped, etc. are handled today via partner-defined types (bella.order.paidposts as a system message and Sela reads it as context). A first-classorder.*event contract that flows into Choir'scommerce_orderstable is on the roadmap.- A snapshot-completion signal — no
commerce.catalog.snapshot_completeevent yet. Choir infers "done" from the catalog stopping being empty; batch-completion signaling comes with the variant work.
14.6. Rate-limiting + backpressure
Choir doesn't rate-limit inbound events on the product.* path today. If you emit 50k products in a snapshot response, each is a signed HTTP POST — sequential is fine for catalogs up to ~5k; parallelize (semaphore of 8-16) beyond that. Debounce partner-side if a merchant hits the "Ask partner to resend" button repeatedly.
15. Fulfillment vocabulary — the order.* + fulfillment.* events
If your product is a courier / 3PL / last-mile logistics platform, or a merchant back-office that owns its own fulfillment status machine, Choir's Fulfillment module recognizes two event names that flow both ways over CAP:
| Event type | Emitter → Receiver | Purpose |
|---|---|---|
order.status_changed | Courier partner → Choir fulfillment workspace | Report a POD-chain status transition (shipped, delivered, exception, cancelled) |
order.status_changed | Choir → merchant's back-office CAP connection | Mirror Choir's FulfillmentOrder state into the merchant's own systems |
fulfillment.tracking_event | Courier partner → Choir fulfillment workspace | Append a hub scan / GPS ping / hand-off event to the append-only tracking history |
fulfillment.tracking_event | Choir → merchant's back-office CAP connection | Mirror every tracking row into the merchant's own systems |
Same event names, same attrs, both directions. What decides who reads/writes is the CAP connection's audience and channel scope. Choir's outbound side is automatic — every FulfillmentOrder status transition and every tracking_events row fans out to every active CAP connection in the merchant's workspace, best-effort per connection.
15.1. Event payloads
order.status_changed
@dataclass
class OrderStatusChangedAttrs:
order_ref: str # merchant-facing order id
to_status: str # one of FULFILLMENT_ORDER_STATUSES
from_status: Optional[str] # previous status if known
occurred_at: Optional[str] # ISO-8601; defaults to now
note: Optional[str] # exception detail, tracking numbers, etc.
courier_reference: Optional[str]
Valid to_status values: pending | allocated | picking | packed | labelled | shipped | delivered | exception | cancelled.
fulfillment.tracking_event
@dataclass
class FulfillmentTrackingEventAttrs:
order_ref: str
kind: str # one of TRACKING_EVENT_KINDS
occurred_at: Optional[str]
location: Optional[str]
note: Optional[str]
courier_reference: Optional[str]
Valid kind values: accepted | picked_up | in_transit | out_for_delivery | delivered | exception.
15.2. Emitting (courier CAP partners)
from cap_partner import CapPartnerClient
from cap_partner.fulfillment import (
emit_order_status_changed, emit_fulfillment_tracking_event,
OrderStatusChangedAttrs, FulfillmentTrackingEventAttrs,
)
# Rider taps "picked up" in the courier app
emit_fulfillment_tracking_event(client,
workspace="acme-fulfillment", channel="dispatch",
attrs=FulfillmentTrackingEventAttrs(
order_ref="SO-00042", kind="picked_up",
location="Lusaka warehouse", courier_reference="LGN-887",
),
)
# Parcel arrives at customer's door
emit_order_status_changed(client,
workspace="acme-fulfillment", channel="dispatch",
attrs=OrderStatusChangedAttrs(
order_ref="SO-00042", to_status="delivered",
courier_reference="LGN-887",
),
)
TypeScript is identical shape — see fulfillment.ts.
15.3. Consuming (merchant back-office partners)
You RECEIVE these on your CAP inbound whenever a fulfillment workspace connected to your merchant runs a status transition or writes a tracking row. Handle them the same way you'd handle any other event: check envelope["payload"]["event_type"] and route accordingly. Choir uses the merchant's own commerce_order.number as order_ref so you can join back to your side without extra state.
15.4. Idempotency
order.status_changed: multiple events for the same(order_ref, to_status)are safe to no-op on the receiving side. Choir on the receiving side uses whatever state machine the consumer wires up — the wire format doesn't enforce ordering, so a partner replaying the last N events after a downtime window is fine.fulfillment.tracking_event: append-only. A partner replaying the same event with the same(order_ref, kind, occurred_at)will duplicate on Choir's side; usecourier_referenceas a de-dupe key partner-side if needed.
15.5. Resolving POD attachments (image bytes)
fulfillment.tracking_event.attrs.attachment_ids carries an array of MessageAttachment UUIDs (usually POD photos captured by the driver on mark-delivered / mark-exception). To fetch the actual bytes, exchange each id for a signed download URL:
GET {choir_base_url}/attachments/{attachment_id}/download-url
Authorization: Bearer chk_... ← workspace API token, scope: fulfillment:pod:read
→ 200 { "url": "<signed GCS/S3 URL>", "expires_at": "2026-08-31T13:00:00Z" }
The signed URL is TTL-bound (~1 hour); fetch the bytes with a plain GET (no auth on the signed URL itself). Refresh by re-hitting /download-url if you need the image after expiry.
Minting the token. Any workspace admin can create one from Workspace settings → API tokens → New token. Grant only the fulfillment:pod:read scope; tokens holding this scope can resolve tracking-event attachments in that workspace and nothing else — message attachments still require a user session even for admins on that same token.
Guardrails on the endpoint:
- API-token callers are refused for any attachment whose
tracking_event_idis null (i.e. message attachments), even in the same workspace. - API-token callers are refused for attachments belonging to a workspace other than the token's own.
- JWT callers keep their existing rights (uploader always; workspace member for POD photos; channel viewer for message attachments).
16. Commerce order lifecycle — the order.* lifecycle events
Complement to §15. Where §15 covers post-allocation POD-chain state a courier or FC ops team touches, this section covers pre-fulfillment lifecycle a merchant's back-office cares about — ledger writes, receipt emails, refund automations, revenue dashboards.
Wire direction is one-way: Choir → partner. Choir emits these into every active CAP connection on the MERCHANT workspace (contrast with §15's fan-out, which targets the fulfillment workspace). A partner does not create Choir orders via CAP — that's what the REST commerce-orders surface is for. The SDKs still ship emit_* helpers for symmetry and test-harness use.
16.1. Event names
| Event | When Choir fires it |
|---|---|
order.created | Payment link issued for a new order (CommerceOrdersService.createOrder). |
order.paid | Paystack signals charge.success; the order transitions to paid. |
order.refunded | Merchant hits the "Refund" advance action. |
order.cancelled | Merchant hits the "Cancel" advance action. |
The Slice-5 order.status_changed event is still the right choice inside a fulfillment workspace (courier reporting POD-chain state). It is not redundant with the events in this section: order.status_changed runs on the FC side and covers packing/shipping/delivery; the events here run on the merchant side and cover payment lifecycle. A back-office partner that subscribes to both gets a clean split between "money moved" and "parcel moved."
16.2. Payload — OrderLifecycleAttrs
Same shape for every event in this vocabulary; only the event name varies.
| Field | Type | Notes |
|---|---|---|
order_ref | string | Human-facing number (SO-00042) when assigned, else the internal id. |
order_id | string | Internal Choir order id (ULID). Sent alongside order_ref so you can key by whichever you prefer. |
status | string | One of draft | quoted | paid | packed | shipped | delivered | cancelled | refunded. Matches commerce_orders.status server-side. |
currency | string | ISO 4217 currency code (upper-case). |
total | number | Order total in the account currency. Plain number, not minor-unit int. |
occurred_at | string? | ISO-8601 timestamp of the transition. Defaults to now. |
conversation_id | string? | The commerce conversation the order lives in. |
payment_reference | string? | Paystack reference for the paying charge. Present on order.paid and later. |
paid_at | shipped_at | delivered_at | string? | ISO-8601. Populated as those transitions happen. |
actor_user_id | string? | Who ran the transition. Null on Paystack-driven paid (no human actor). |
note | string? | Free-text note the merchant attached on advance (typically on refund/cancel). |
16.3. Consuming the events (Python)
def on_inbound(env):
if env["turn"] != "event":
return {"ok": True}
body = env["body"]
if body["event_type"] == "order.paid":
attrs = body["attrs"]
my_ledger.record_sale(
order_ref=attrs["order_ref"], total=attrs["total"],
currency=attrs["currency"], paid_at=attrs.get("paid_at"),
paystack_ref=attrs.get("payment_reference"),
)
elif body["event_type"] == "order.refunded":
my_ledger.record_refund(order_ref=body["attrs"]["order_ref"],
note=body["attrs"].get("note"))
return {"ok": True}
16.4. Idempotency
- Same
(order_ref, event_type)may arrive more than once during a re-delivery window. Consumers should upsert against a(source, order_ref, event_type)key rather than blindly appending. - Order-of-arrival: Choir emits synchronously off the state change, but network re-tries can invert order between two events on the same order. Trust
status+ the timestamp fields over event order.
17. Importing already-paid orders — commerce.order.imported
Closes the gap Bosso engineers caught in the 0.5.0 timeframe: before this event landed, the only way a commerce_order could exist was via Choir's own commerce inbox (Sela drafts a payment link → Paystack success). Externally-originated orders — the WhatsApp sale a partner already collected payment on — had nowhere to land. This event opens the door.
17.1. When you'd use it
You're already collecting payment on your side (Bella, an ERP, a custom checkout, etc.) and you want the order to flow through Choir's fulfillment pipeline — i.e. show up on the FC operator's Fulfill queue, get a fulfillment_order allocated, and (assuming the merchant has an active FC contract) route to the right centre based on region + rules.
17.2. Wire format
Standard CAP event turn, event_type='commerce.order.imported'.
Payload — ImportedOrderAttrs
| Field | Type | Notes |
|---|---|---|
external_ref | string | Required. Your stable order id. Choir combines it with the envelope iss to form a unique replay guard (payment_reference = cap:${iss}:${external_ref}). |
total | number | Required. Order total in the account currency, major units. |
currency | string | Required. ISO-4217 code, upper-cased server-side. |
paid_at | string | Required. ISO-8601. When you consider the order paid. |
customer.phone | .email | .wa_id | string | At least ONE required. Choir upserts the Customer by (workspace_id, kind, value). Phone is recommended when you have it. |
customer.display_name | string | Optional. Populates the customer row's display name on first insert. |
order_number | string | Optional. Merchant-facing number; if omitted, Choir generates one at first advance. |
shipping_address | string | Optional. Free-form; rendered verbatim to drivers on the fulfillment page. |
region | string | Optional. Participates in the merchant-vs-FC region-coverage match at allocation. |
line_items | array of { name, quantity, sku?, variant?, catalog_product_id? } | Optional. Recommended so drivers see what to pack. |
metadata | object | Optional. Free-form; stored on the commerce_order's payload column. |
17.3. Example (Python)
from cap_partner import (
ImportedOrderAttrs, ImportedOrderCustomer, ImportedOrderLineItem,
emit_commerce_order_imported,
)
emit_commerce_order_imported(client,
workspace="bosso",
channel="orders", # any channel your connection is scoped to
attrs=ImportedOrderAttrs(
external_ref="BOSSO-4471",
total=234.50,
currency="ZMW",
paid_at="2026-08-31T09:30:00Z",
customer=ImportedOrderCustomer(
phone="+260971111111",
display_name="Jane Doe",
),
order_number="BOSSO-4471",
shipping_address="123 Cairo Rd, Lusaka",
region="ZM-Lusaka",
line_items=[
ImportedOrderLineItem(name="Roofing Sheet · Blue", quantity=3, sku="RS-BLU-04"),
],
),
)
TypeScript is identical shape — see orders.ts — emitCommerceOrderImported.
17.4. What Choir does on receipt
- Verifies envelope signature + connection scope (same as any other CAP inbound).
- Validates
attrs(rejects with a log line if required fields are missing or malformed — no exception thrown into the partner path). - Upserts the Customer by identity (
phone>wa_id>emailin priority order). - Idempotency check: if a
commerce_orderwithpayment_reference = cap:${iss}:${external_ref}already exists, no-ops. Safe to replay envelopes. - Inserts a new
commerce_orderwith:status='paid'payment_provider='external'paid_atset to the envelope valuepayment_referenceset to the synthetic key abovepayloadcarrying line items, shipping address, region, metadata, cap_iss
- Emits
commerce.order.paidinternally →FulfillmentOrderService's existing listener allocates afulfillment_orderimmediately (subject to merchant contracts / region coverage). - Emits
order.paidon CAP outbound → every merchant-workspace CAP subscriber (including Bella's own back-office feed) sees the imported order in the P2 "money moved" stream, just like a Sela-driven paid.
17.5. When it silently no-ops (worth knowing)
- Same envelope twice. Second delivery finds the existing row, logs
commerce.order.imported idempotent: existing order ..., returns. - Missing customer identity. Logged at WARN, no writes.
- Bad currency / negative total / non-ISO paid_at. Logged at WARN, no writes. Fix the payload and re-emit.
- Merchant has no active FC contract for the region. The commerce_order is created +
paid, but nofulfillment_orderallocates. A system message posts in the merchant's escalation channel (fulfillment_allocation_missing). Set up a contract, then re-emit or advance manually.
17.6. Also useful — no cross-list "everything I own" view yet
Not related to this event, but flagged in the same conversation: engineers/ops folks who own items across multiple lists currently open one tab per list. The Focus view (mentioned in docs/lists.md) hasn't shipped yet.
18. Async result handling for tool_request — the correlator pattern
Important — the reply I made to Bosso got this wrong at first. The docs elsewhere on this page imply that sendToolRequest returns the tool's result. It doesn't. The wire is async:
- Your
sendToolRequestHTTP call returns{ accepted: true }once Choir has verified the envelope + written acap_tool_callsrow. That's it — no result yet. - Choir runs the tool asynchronously (may be milliseconds; may be many seconds).
- Choir signs a
tool_resultenvelope and POSTs it to your/inboundwebhook. The correlation key ispayload.call_id— matches whatever you sent. - Your
/inboundhandler routes the envelope to whatever code was waiting on thatcall_id.
Every partner writing a coding agent, an interactive UI, or a test harness against list.* hits this at chunk 3. Since SDK v0.7 the correlator lives in the SDK itself — use sendToolRequestAndWait / send_tool_request_and_wait and get a promise / blocking call back instead of building your own map.
18.1. Using the SDK helper (v0.7+)
TypeScript:
import { CapPartnerClient } from '@choirhq/cap-partner';
const client = new CapPartnerClient({ /* ... */ });
// Wire the correlator into your inbound webhook — MUST be the
// same client instance the send call is running on.
app.post('/inbound', async (req, res) => {
const env = await client.verifyInbound(req.body);
// Route tool_result envelopes to any waiting sendToolRequestAndWait.
// Safe to call on non-tool_result envelopes (returns false).
const routed = client.resolveInboundToolResult(env);
if (routed) return res.json({ ok: true });
// Fall through to your other dispatch (say / event / propose /
// tool_request FROM Choir requesting one of YOUR tools).
// ...
res.json({ ok: true });
});
// Now anywhere in your code:
const outcome = await client.sendToolRequestAndWait({
workspace: 'bosso', channel: 'tech-backlog',
toolName: 'list.item.add',
args: { list_id: '<uuid>', body: 'Refund flow drops merchant timezone' },
timeoutMs: 30_000, // default 30s
});
// outcome.status === 'ok' | 'error'
// outcome.result (on ok) or outcome.error (on error)
Python:
from cap_partner import CapPartnerClient, CapPartnerConfig
client = CapPartnerClient(CapPartnerConfig(...))
@app.post('/inbound')
def inbound(env: dict):
verified = client.verify_inbound(env)
if client.resolve_inbound_tool_result(verified):
return {'ok': True}
# fall through to your other dispatch
return {'ok': True}
# Anywhere:
outcome = client.send_tool_request_and_wait(
workspace='bosso', channel='tech-backlog',
tool_name='list.item.add',
args={'list_id': '<uuid>', 'body': 'Refund flow drops merchant timezone'},
timeout_sec=30.0,
)
# outcome['status'] == 'ok' | 'error'
# outcome['result'] or outcome['error']
18.2. Timeout, wiring failures, multi-instance
- 30s default on both languages. Override per call.
- Timeout throws / rejects with a message that names the wiring gap —
"is your /inbound webhook wired to resolveInboundToolResult?". If you're seeing that when the wiring is right, check for a client-instance mismatch (see multi-instance below). - Multi-instance caveat. The correlator is an in-memory map on the client instance. If your web tier has multiple replicas and Choir's
tool_resultPOST lands on a different replica than the one that made thesendToolRequestAndWaitcall, the waiter never resolves. Options:- Pin the call to the receiving replica (a session-affinity header, or a dedicated sidecar that handles all tool round-trips).
- Use plain
sendToolRequest+ your own Redis / DB correlator for cross-replica coordination. - We'll ship a Redis-backed waiter option when a real partner needs it.
18.3. When you still want plain sendToolRequest
Use it when:
- You're implementing a webhook-driven flow that fires-and-forgets (e.g. Bella filing a bug from a Slack
/bugcommand — no code path is waiting for a return). - You need cross-replica coordination beyond what a per-process correlator gives you.
- You want to log the correlation yourself with additional metadata Choir doesn't see.
The manual correlator pattern (what Bosso built in PR #322):
const waiters = new Map<string, (payload: unknown) => void>();
client.sendToolRequest({ ..., callId: 'my-call-1' });
const p = new Promise(resolve => waiters.set('my-call-1', resolve));
// in /inbound:
if (env.turn === 'tool_result') {
const cb = waiters.get(env.payload.call_id);
if (cb) { waiters.delete(env.payload.call_id); cb(env.payload); }
}
const outcome = await p;
Same shape the SDK's sendToolRequestAndWait implements internally.
19. Commerce inbox — bridging customer conversations from your platform
If your product is a customer-facing commerce platform (a WhatsApp-first storefront, a marketplace app, an appointment booker, anything where a merchant's customers chat with the platform), Choir can bridge those conversations into the merchant's Choir workspace so their team gets an on-the-go / shared-inbox / escalation surface on top of what your platform already does.
This section is for partners in that position — Uzara, Bella (customer-side), Bosso's storefront widget, any similar system.
19.1. Two modes — per merchant, flip anytime
Mode choir_native — Your platform is a transport bridge. You forward the raw customer message to Choir; Sela (Choir's built-in commerce voice) drafts / sends the reply; Choir owns catalog / checkout / KB / tickets. You send outbound back through your channel (WhatsApp, IG, in-app chat, whatever).
Mode external_agent — You keep your own AI (Xana, Bella's own, whatever). Choir is the escalation + shared-inbox surface. You forward the full transcript so humans on the merchant side can see everything; you fire propose turns when Xana needs a human decision; Sela stays silent on these conversations by design.
An admin in the merchant workspace flips a partner between modes from Settings → Connections. No re-integration needed; same wire.
19.2. Two scopes — pick per rollout
Scope A — Platform HQ as one CAP partner. One iss (e.g. uzara.ai), mirrors every tenant's traffic into ONE Choir workspace owned by HQ. aud = "<hq-slug>/<tenant-slug>" scopes each conversation into a per-tenant channel.
Scope B — Per-tenant CAP partner. Merchant tenant registers their own iss (<merchant-slug>.<your-domain>) into their own Choir workspace. Only their customers appear.
Both work off the same integration on your side — scope is just which iss + aud each envelope carries.
19.3. Turns your platform emits (partner → Choir)
| Signal | Turn | Payload |
|---|---|---|
| Customer sent a message | say | { body, author: { external_id, display_name, kind: "customer" } } |
| Your AI replied autonomously | say | { body, author: { external_id, display_name, kind: "agent" } } |
| Merchant staff replied on your side | say | { body, author: { external_id, display_name, kind: "human" } } |
| Lifecycle transition | event | { event_type: "commerce.customer.opened_chat" | "commerce.order.created" | ..., attrs: {...} } |
| Your AI wants a human decision | propose | { proposal_id, title, description, action: { tool_name: "commerce.escalation.decide", args: {...} }, expires_at } |
Every envelope: aud = "<workspace-slug>/<channel-slug>". Scope A → <hq-slug>/<tenant-slug>. Scope B → <merchant-workspace>/customer-inbox.
author.kind vocabulary (semantic hints Choir uses to render each message correctly):
"customer"— the buyer / end-user"agent"— your platform's AI (Xana, etc.). Choir renders as a distinct voice-authored message"human"— human staff on your platform side. Choir renders as a teammate message
19.4. Canonical commerce.* event catalogue
Use these event names on the event turn so Choir routes + renders consistently. Anything partner-specific stays under your own namespace (uzara.gift_card.redeemed, bella.loyalty.grant) — those show up as generic event cards, not first-class.
event_type | When to send | Suggested attrs |
|---|---|---|
commerce.customer.opened_chat | First message from a customer that starts a new conversation | { conversation_id, channel: "whatsapp" | "in_app" | ..., customer: { external_id, display_name?, phone?, email? } } |
commerce.customer.identity_added | Customer shared a new identity mid-thread | { conversation_id, identity: { kind: "email" | "phone" | "instagram", value } } |
commerce.order.created | Draft / quote / pending order created on your side | { order_ref, currency, total, items?: [...] } |
commerce.order.paid | Payment settled | { order_ref, currency, total, method?, reference? } |
commerce.order.refunded | Refund posted | { order_ref, currency, amount, reason? } |
commerce.order.cancelled | Order voided before fulfillment | { order_ref, reason? } |
commerce.cart.abandoned | Cart went idle past your threshold | { cart_ref, currency, total, items? } |
commerce.customer.opt_in | Marketing / notification opt-in | { conversation_id, channel } |
commerce.customer.opt_out | Marketing opt-out (STOP / UNSUBSCRIBE) | { conversation_id, channel } |
commerce.customer.reachable_via_wa | 24hr WhatsApp session opened / re-opened | { conversation_id, opened_at } |
If you need a new canonical event, PR it into this list — cross-partner interop stays worth more than per-partner cleverness.
19.5. Standard tools you should advertise (Choir → partner)
Declare these in your manifest so Choir can invoke them from the merchant's inbox. Implement only the ones you support; Choir hides UI for what you haven't advertised.
| Tool name | When Choir invokes | Args |
|---|---|---|
commerce.customer.reply | Merchant taps send in Choir's inbox | { conversation_id, body, author_display_name, author_kind: "human" | "agent" } |
commerce.customer.reply_with_media | Reply carries an attachment | Above + { media_url, media_kind, filename? } |
commerce.escalation.decide | Merchant tapped a decision on a propose card you sent | { proposal_id, decision: "approve" | "reject" | "edit", edit?: {...} } |
commerce.order.query (optional) | Merchant opens an order detail view in Choir | { order_ref } → return OrderSnapshot |
commerce.order.refund (optional) | Refund fired from Choir | { order_ref, currency, amount, reason } |
commerce.customer.assign_to_agent (optional) | Merchant explicitly hands the conversation back to your AI | { conversation_id } |
Reply with a tool_result (ok / error). See §18 for the async-correlator pattern.
19.6. Minimum viable integration
Ship in this order — each stage is independently useful:
- Read-only mirror — implement the
say+eventsends. Merchant sees the conversation + lifecycle events in Choir; cannot reply yet. Zero risk, validates the wire. - Reply-through — advertise
commerce.customer.replyin your manifest, handle it in/inbound(deliver the reply through whatever channel the customer is on). Merchant can send from Choir. - Escalations — send
proposeturns whenever your AI needs a human, advertisecommerce.escalation.decide, handle it in/inbound. Highest-value slice. - Mode-shift UX — surface a "Also handle in Choir (Sela)" toggle in your merchant admin. When on, don't send your AI's
sayturns (only customer + human) so Sela owns the reply. Same iss, same wire; you just stop competing.
19.7. Reference implementation
Bosso runs a version of this pattern for the fulfillment side already (§15). Their commerce-side integration is on the roadmap and will use the exact vocabulary in this section. Ping us for a live demo.
20. 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". - Commerce
product.*vocabulary + snapshot handshake (SDK v0.3.0) — first domain-specific event set that Choir routes into a first-party module. If your product is a shop backend,product.created/updated/deleted/stock.changedand thecommerce.catalog.snapshot_requestevent let you keep the workspace catalog in sync entirely over CAP — no side-channel REST, no seed script. Full contract in §14. - Fulfillment
order.status_changed+fulfillment.tracking_event(SDK v0.4.0) — symmetric vocabulary for POD-chain state. Courier CAP partners emit inward to a fulfillment workspace; Choir emits outward to a merchant's back-office CAP connection on every FulfillmentOrder transition and every tracking row. Full contract in §15. - Commerce order lifecycle
order.created/order.paid/order.refunded/order.cancelled(SDK v0.5.0) — one-way Choir → partner vocabulary for the pre-fulfillment lifecycle. If your product is a back-office (ledger, ERP, revenue dashboards), subscribing to these gives you a "money moved" stream that is cleanly split from the §15 "parcel moved" stream. Full contract in §16. - Order import
commerce.order.imported(SDK v0.6.0) — one-way partner → Choir. Hand Choir an already-paid, externally-originated order and Choir's fulfillment pipeline picks it up automatically — no need to route customer conversations through Sela's commerce inbox. Full contract in §17. - Commerce inbox bridge — canonical
commerce.*events + standard reply/escalation tools — if your product is a customer-facing platform (WhatsApp storefront, marketplace app, appointment booker), you can now mirror customer conversations into a Choir workspace and expose reply + escalation-decision tools using one standard vocabulary. Two modes flippable per merchant:choir_native(Sela handles reply) orexternal_agent(your AI handles it; Choir is the escalation surface). Full contract in §19.
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.