Home/CAP integration

Integrating your product with Choir over CAP

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

Estimated time to first signed envelope: 30 minutes.


Contents

  1. What CAP is, in one minute
  2. Pick your stack
  3. Install the SDK locally — Python + TypeScript
  4. Configure your environment
  5. Stand up your three endpoints
  6. Write your manifest — includes a realistic example
  7. Register with Choir
  8. Send your first envelope
  9. Handle inbound envelopes from Choir
  10. The propose pattern — human-in-loop for sensitive actions
  11. Tool grants — what admins control
  12. Testing the round-trip
  13. FAQ + troubleshooting
  14. Commerce vocabulary — the product.* events
  15. Fulfillment vocabulary — the order.status_changed + fulfillment.tracking_event events
  16. Commerce order lifecycle — the order.* lifecycle events
  17. Importing already-paid orders — commerce.order.imported
  18. Async result handling for tool_request — the correlator pattern
  19. 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.

ShapeYou 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 countYour advertised manifest tools as callable functions with typed args + returnstool_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 languageYour partner ONLY as a chat-scoped conversation surface. Manifest tools stay hidden even if you declare themsay 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 conversational for 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 — just iss, name, jwks_url, inbound_url. Your inbound handles ONE turn type — say — and passes the body straight to your AI. No per-tool schema, no tool_request handling, 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's iss field)
  • 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 isUseReference sample
Python (FastAPI, Django, Flask, scripts)cap-partnerexamples/sample_partner.py
TypeScript / Node (Express, NestJS, Fastify, scripts)@choirhq/cap-partnerexamples/sample-partner.ts

Both SDKs:

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

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


3. Install the SDK

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

Python

pip install cap-partner

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

cap-partner~=0.2

Verify the install

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

TypeScript / Node

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

Verify the install

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

Installing from local source (SDK contributors only)

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

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

Python (editable):

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

TypeScript:

npm install <CHOIR_REPO>/cap-sdks/typescript

For TypeScript, contributors must rebuild after source changes:

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

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


4. Configure your environment

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

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

Then in your service env:

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

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

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

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

5. Stand up your three endpoints

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

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

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

Python (FastAPI) skeleton

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

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

app = FastAPI()

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

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

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

TypeScript (Express) skeleton

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

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

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

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

app.listen(9001);

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


6. Write your manifest

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

Minimum required shape

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

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

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

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

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

Validation rules Choir applies

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

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

Risk levels — what they mean in practice

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

Renaming a tool? Don't.

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


7. Register with Choir

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

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

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

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

After registration, each workspace admin separately:

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

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

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 aud claim, the workspace= argument to client.send_say / client.sendSay, and the JWKS path /cap/<slug>/.well-known/jwks.json) use the workspace slug — a human-readable identifier like acme or bosso-africa-inc.

Putting a UUID where the slug belongs returns a 404 from /cap/v1/inbound ("workspace not found") because the inbound handler does workspaces.findOne({ where: { slug: aud.workspaceSlug } }). The SDK throws a clear local error when the workspace= 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.

TurnWhen to useHow it renders in the channel
sayAnything 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.
eventStructured 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.
proposeAnything 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:

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

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

What to do with caller:

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

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


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

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

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

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

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

Why this is the high-value pattern

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

Idempotency

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


11. Tool grants — what admins control

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

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

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

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

What happens on inbound when there's no grant?

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

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

tool_not_advertised vs tool_not_granted

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

12. Testing the round-trip

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

1. Run the sample partner locally first

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

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

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

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

2. Smoke test in three calls

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

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

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

3. Then swap your real implementation

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

Local manifest-fetch checklist

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

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

JWKS + /inbound need the same public reachability.


13. FAQ + troubleshooting

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

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

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

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

How do I rotate my signing key?

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

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

Can I send envelopes to multiple workspaces at once?

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

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

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

What's the rate limit?

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

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

In order:

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

Where do I file a bug?

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


14. 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_typePayload shapeWhen to emit
product.createdProductAttrs (full record)New product row in your DB
product.updatedProductAttrs (full record)Any edit — name, price, image, tags, stock, etc.
product.deleted{ external_ref } onlyRemoved / 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_requestChoir → you; see §14.3 belowYou 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.created on the same external_ref is safe — Choir upserts.
  • Emitting product.updated after product.created for the same external_ref behaves 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_ref for 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.changed accepts a variant_external_ref field 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.paid posts as a system message and Sela reads it as context). A first-class order.* event contract that flows into Choir's commerce_orders table is on the roadmap.
  • A snapshot-completion signal — no commerce.catalog.snapshot_complete event 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 typeEmitter → ReceiverPurpose
order.status_changedCourier partner → Choir fulfillment workspaceReport a POD-chain status transition (shipped, delivered, exception, cancelled)
order.status_changedChoir → merchant's back-office CAP connectionMirror Choir's FulfillmentOrder state into the merchant's own systems
fulfillment.tracking_eventCourier partner → Choir fulfillment workspaceAppend a hub scan / GPS ping / hand-off event to the append-only tracking history
fulfillment.tracking_eventChoir → merchant's back-office CAP connectionMirror 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; use courier_reference as 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_id is 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

EventWhen Choir fires it
order.createdPayment link issued for a new order (CommerceOrdersService.createOrder).
order.paidPaystack signals charge.success; the order transitions to paid.
order.refundedMerchant hits the "Refund" advance action.
order.cancelledMerchant 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.

FieldTypeNotes
order_refstringHuman-facing number (SO-00042) when assigned, else the internal id.
order_idstringInternal Choir order id (ULID). Sent alongside order_ref so you can key by whichever you prefer.
statusstringOne of draft | quoted | paid | packed | shipped | delivered | cancelled | refunded. Matches commerce_orders.status server-side.
currencystringISO 4217 currency code (upper-case).
totalnumberOrder total in the account currency. Plain number, not minor-unit int.
occurred_atstring?ISO-8601 timestamp of the transition. Defaults to now.
conversation_idstring?The commerce conversation the order lives in.
payment_referencestring?Paystack reference for the paying charge. Present on order.paid and later.
paid_at | shipped_at | delivered_atstring?ISO-8601. Populated as those transitions happen.
actor_user_idstring?Who ran the transition. Null on Paystack-driven paid (no human actor).
notestring?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

FieldTypeNotes
external_refstringRequired. Your stable order id. Choir combines it with the envelope iss to form a unique replay guard (payment_reference = cap:${iss}:${external_ref}).
totalnumberRequired. Order total in the account currency, major units.
currencystringRequired. ISO-4217 code, upper-cased server-side.
paid_atstringRequired. ISO-8601. When you consider the order paid.
customer.phone | .email | .wa_idstringAt least ONE required. Choir upserts the Customer by (workspace_id, kind, value). Phone is recommended when you have it.
customer.display_namestringOptional. Populates the customer row's display name on first insert.
order_numberstringOptional. Merchant-facing number; if omitted, Choir generates one at first advance.
shipping_addressstringOptional. Free-form; rendered verbatim to drivers on the fulfillment page.
regionstringOptional. Participates in the merchant-vs-FC region-coverage match at allocation.
line_itemsarray of { name, quantity, sku?, variant?, catalog_product_id? }Optional. Recommended so drivers see what to pack.
metadataobjectOptional. 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.tsemitCommerceOrderImported.

17.4. What Choir does on receipt

  1. Verifies envelope signature + connection scope (same as any other CAP inbound).
  2. Validates attrs (rejects with a log line if required fields are missing or malformed — no exception thrown into the partner path).
  3. Upserts the Customer by identity (phone > wa_id > email in priority order).
  4. Idempotency check: if a commerce_order with payment_reference = cap:${iss}:${external_ref} already exists, no-ops. Safe to replay envelopes.
  5. Inserts a new commerce_order with:
    • status='paid'
    • payment_provider='external'
    • paid_at set to the envelope value
    • payment_reference set to the synthetic key above
    • payload carrying line items, shipping address, region, metadata, cap_iss
  6. Emits commerce.order.paid internally → FulfillmentOrderService's existing listener allocates a fulfillment_order immediately (subject to merchant contracts / region coverage).
  7. Emits order.paid on 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 no fulfillment_order allocates. 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:

  1. Your sendToolRequest HTTP call returns { accepted: true } once Choir has verified the envelope + written a cap_tool_calls row. That's it — no result yet.
  2. Choir runs the tool asynchronously (may be milliseconds; may be many seconds).
  3. Choir signs a tool_result envelope and POSTs it to your /inbound webhook. The correlation key is payload.call_id — matches whatever you sent.
  4. Your /inbound handler routes the envelope to whatever code was waiting on that call_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_result POST lands on a different replica than the one that made the sendToolRequestAndWait call, 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 /bug command — 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)

SignalTurnPayload
Customer sent a messagesay{ body, author: { external_id, display_name, kind: "customer" } }
Your AI replied autonomouslysay{ body, author: { external_id, display_name, kind: "agent" } }
Merchant staff replied on your sidesay{ body, author: { external_id, display_name, kind: "human" } }
Lifecycle transitionevent{ event_type: "commerce.customer.opened_chat" | "commerce.order.created" | ..., attrs: {...} }
Your AI wants a human decisionpropose{ 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_typeWhen to sendSuggested attrs
commerce.customer.opened_chatFirst 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_addedCustomer shared a new identity mid-thread{ conversation_id, identity: { kind: "email" | "phone" | "instagram", value } }
commerce.order.createdDraft / quote / pending order created on your side{ order_ref, currency, total, items?: [...] }
commerce.order.paidPayment settled{ order_ref, currency, total, method?, reference? }
commerce.order.refundedRefund posted{ order_ref, currency, amount, reason? }
commerce.order.cancelledOrder voided before fulfillment{ order_ref, reason? }
commerce.cart.abandonedCart went idle past your threshold{ cart_ref, currency, total, items? }
commerce.customer.opt_inMarketing / notification opt-in{ conversation_id, channel }
commerce.customer.opt_outMarketing opt-out (STOP / UNSUBSCRIBE){ conversation_id, channel }
commerce.customer.reachable_via_wa24hr 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 nameWhen Choir invokesArgs
commerce.customer.replyMerchant taps send in Choir's inbox{ conversation_id, body, author_display_name, author_kind: "human" | "agent" }
commerce.customer.reply_with_mediaReply carries an attachmentAbove + { media_url, media_kind, filename? }
commerce.escalation.decideMerchant 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:

  1. Read-only mirror — implement the say + event sends. Merchant sees the conversation + lifecycle events in Choir; cannot reply yet. Zero risk, validates the wire.
  2. Reply-through — advertise commerce.customer.reply in your manifest, handle it in /inbound (deliver the reply through whatever channel the customer is on). Merchant can send from Choir.
  3. Escalations — send propose turns whenever your AI needs a human, advertise commerce.escalation.decide, handle it in /inbound. Highest-value slice.
  4. Mode-shift UX — surface a "Also handle in Choir (Sela)" toggle in your merchant admin. When on, don't send your AI's say turns (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 registriespip install cap-partner and npm install @choirhq/cap-partner are live. Local-path install (§3 contributor section) is now only for SDK contributors, not regular partner integrators.
  • Direction-B tool calls — Choir Voices invoking your tools from inside their tool palette. Live since v0.2. Your code doesn't change; you'll just see tool_request envelopes arrive during real Voice turns, not only when an admin triggers a test. Plan for higher + spikier traffic; keep call_id correlation tight. See KB article cap-voice-tools-from-partners for the admin's-eye view.
  • Phase 2 caller identitytool_request envelopes now include an optional caller block (user_id + role + is_staff) so you can perform partner-side RBAC in addition to Choir's per-channel grants. See §9 "Caller-based RBAC".
  • 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.changed and the commerce.catalog.snapshot_request event 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) or external_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_request whose tool_result lands on a different Choir instance (behind a load balancer) will time out. Single-instance Choir deployments unaffected. Redis pub/sub or DB-poll variant on the roadmap.
  • requires_propose server-side enforcement — today it's an admin-UI hint; we'll block direct tool_request for requires_propose: true tools at the inbound handler in a later phase.
  • Cross-workspace tool invocation — today envelopes route to one workspace/channel. A future version may let Voices in workspace A invoke tools in workspace B (with both workspaces' admin consent).

Maybe (depends on demand)

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

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


Quick reference card

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

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

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

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

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

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