From 4177469dae8fe3fbb9abc224cba8a01b4be66fe6 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Thu, 2 Jul 2026 08:46:21 -0400 Subject: [PATCH 1/2] feat: conference-supervision example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distilled supervision patterns from the room-monitor reference app (https://github.com/jambonz/room-monitor): ws-app.ts runs the caller (portal- editable ROOM_NAME env var), agent (memberTag), supervisor (joinMuted + actionHook), and audio-fork-sink flows; monitor.ts drives the REST side — room discovery, coach/barge/monitor mode switches on the live leg, dynamic tag/untag, and the conference listen fork start/stop. Served whole by the MCP server's get_sdk_example. Co-Authored-By: Claude Opus 4.8 --- examples/conference-supervision/README.md | 61 +++++++++++++ examples/conference-supervision/monitor.ts | 93 +++++++++++++++++++ examples/conference-supervision/ws-app.ts | 100 +++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 examples/conference-supervision/README.md create mode 100644 examples/conference-supervision/monitor.ts create mode 100644 examples/conference-supervision/ws-app.ts diff --git a/examples/conference-supervision/README.md b/examples/conference-supervision/README.md new file mode 100644 index 0000000..490063a --- /dev/null +++ b/examples/conference-supervision/README.md @@ -0,0 +1,61 @@ +# Conference Supervision + +Monitor, coach, and barge into live conferences, and tap a room's audio for +transcription — the core patterns behind a call-center supervision tool, +distilled into two small files. + +A complete reference application built on these patterns (React console, +diarized live transcript, closed-loop e2e tests) is at +https://github.com/jambonz/room-monitor — see its `docs/ADAPTING.md`. + +## What It Demonstrates + +- **Tagging members** — `memberTag: 'agent'` at join, and mid-call + `tag`/`untag` via `injectCommand('conf:participant-action', ...)` +- **The supervisor leg** — one conference member whose join options and + participant actions produce three modes: silent monitor (`joinMuted`), + coach (audio delivered only to `agent`-tagged members), and barge-in +- **Mid-call mode switching** — `conferenceParticipantAction` + (`coach`/`uncoach`) + `conf_mute_status` via the REST client: no re-dial, + the room is never interrupted +- **Conference listen fork** — jambonz streams the room's mixed audio (L16 + PCM) to your WebSocket for transcription/AI; your `metadata` arrives + verbatim as the fork's first text frame +- **Application env vars** — the caller flow's room name (`ROOM_NAME`) is + declared via OPTIONS discovery so operators edit it from the jambonz portal + +## Prerequisites + +- A jambonz account with API credentials +- Three applications pointed at this app's paths (or one application and + custom `X-` headers — see the reference app): `/caller`, `/agent`, + `/supervisor` +- Conference-level listen (`/Conferences/{name}/listen`) requires a jambonz + release with MediaJam-based conferencing that includes it + +## Environment Variables + +- `JAMBONZ_BASE_URL` — jambonz API URL (default: `https://api.jambonz.us`) +- `JAMBONZ_ACCOUNT_SID` — your account SID +- `JAMBONZ_API_KEY` — your API key +- `FORK_URL` — public ws(s):// URL of this app's `/fork` path (the media + server dials out to it) + +## Files + +- `ws-app.ts` — the jambonz application: caller/agent/supervisor call flows + + the audio-fork sink +- `monitor.ts` — REST-side supervision driver: list live rooms, switch the + supervisor's mode (monitor → coach → barge), tag/untag a participant, + start/stop the transcription fork + +## Try It + +```bash +npm install @jambonz/sdk tsx +npx tsx ws-app.ts # the application (port 3000) +npx tsx monitor.ts # walk a live room through the supervision modes +``` + +Place two calls into a room (one via `/agent`, one via `/caller`), then run +`monitor.ts` with the supervisor's call_sid to step through the modes. diff --git a/examples/conference-supervision/monitor.ts b/examples/conference-supervision/monitor.ts new file mode 100644 index 0000000..eccff4e --- /dev/null +++ b/examples/conference-supervision/monitor.ts @@ -0,0 +1,93 @@ +import { JambonzClient } from '@jambonz/sdk/client'; + +/* + * Conference supervision — the REST-side driver. + * + * Walks a live room through the supervision modes using mid-call commands on + * the supervisor's existing leg (started via ws-app.ts /supervisor). Nothing + * here re-dials or interrupts the room. + * + * Usage: + * JAMBONZ_ACCOUNT_SID=... JAMBONZ_API_KEY=... \ + * npx tsx monitor.ts [room_name] + */ + +const baseUrl = process.env.JAMBONZ_BASE_URL || 'https://api.jambonz.us'; +const accountSid = process.env.JAMBONZ_ACCOUNT_SID || ''; +const apiKey = process.env.JAMBONZ_API_KEY || ''; +const forkUrl = process.env.FORK_URL || 'wss://your-host/fork'; + +const supervisorCallSid = process.argv[2]; +const roomName = process.argv[3] || 'support-room'; +if (!accountSid || !apiKey || !supervisorCallSid) { + console.error('usage: JAMBONZ_ACCOUNT_SID=.. JAMBONZ_API_KEY=.. npx tsx monitor.ts [room]'); + process.exit(1); +} + +const client = new JambonzClient({ baseUrl, accountSid, apiKey }); +const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/* -- discovery: what rooms are live? --------------------------------------- */ +const conferences = await client.conferences.list(); +console.log('live conferences:', conferences); + +// On releases with the enriched listing, get participants + tags in one call: +// GET /Accounts/{sid}/Conferences?expand=participants +// → [{ id, name, durationSec, participants: [{ call_sid, label, memberTag, isAgent }] }] +// Derive agent counts (and whether Coach should be offered) from memberTag, +// and filter out memberTag === 'supervisor' legs before displaying counts. + +/* -- transcription tap: stream the room mix to our websocket --------------- */ +// jambonz only transports audio — the consumer (ws-app.ts /fork) decides what +// to do with it. The fork needs no participant leg, is excluded from counts, +// and is torn down automatically when the room ends. +const res = await fetch( + `${baseUrl}/v1/Accounts/${accountSid}/Conferences/${encodeURIComponent(roomName)}/listen`, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: forkUrl, + sampleRate: 16000, + metadata: { room: roomName, sampleRate: 16000 }, // delivered verbatim to the sink + }), + }); +console.log(`listen fork: ${res.status}`); + +/* -- the three supervision modes, as mid-call commands --------------------- */ + +// COACH: audio delivered only to members tagged 'agent'; the caller — and the +// audio fork — cannot hear it +console.log('coach mode: only agents hear the supervisor'); +await client.calls.update(supervisorCallSid, { + conferenceParticipantAction: { action: 'coach', tag: 'agent' }, +}); +await client.calls.update(supervisorCallSid, { conf_mute_status: 'unmute' }); +await pause(10000); + +// BARGE-IN: heard by everyone +console.log('barge-in: everyone hears the supervisor'); +await client.calls.update(supervisorCallSid, { + conferenceParticipantAction: { action: 'uncoach' }, +}); +await pause(10000); + +// back to SILENT MONITOR +console.log('silent monitor: heard by no one'); +await client.calls.update(supervisorCallSid, { conf_mute_status: 'mute' }); + +/* -- tags are dynamic too -------------------------------------------------- +// promote/demote any live participant without a re-join; an active coach +// starts/stops reaching them immediately: +// await client.calls.update(someCallSid, { +// conferenceParticipantAction: { action: 'tag', tag: 'agent' }, +// }); +// await client.calls.update(someCallSid, { +// conferenceParticipantAction: { action: 'untag' }, +// }); +--------------------------------------------------------------------------- */ + +/* -- stop the transcription tap -------------------------------------------- */ +await fetch( + `${baseUrl}/v1/Accounts/${accountSid}/Conferences/${encodeURIComponent(roomName)}/listen`, + { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` } }); +console.log('listen fork stopped'); diff --git a/examples/conference-supervision/ws-app.ts b/examples/conference-supervision/ws-app.ts new file mode 100644 index 0000000..8d61fbd --- /dev/null +++ b/examples/conference-supervision/ws-app.ts @@ -0,0 +1,100 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; +import type { Session, AudioStream } from '@jambonz/sdk/websocket'; + +/* + * Conference supervision — the jambonz application side. + * + * Three call flows on one endpoint (point one jambonz application at each + * path), plus the audio sink that receives a conference listen fork: + * + * /caller a normal participant; which room they join is the + * application's ROOM_NAME env var (portal-editable via + * OPTIONS discovery) + * /agent joins the same room tagged 'agent' — the coach target + * /supervisor joins muted (silent monitor); mode changes come later as + * mid-call participant actions (see monitor.ts) + * /fork receives the room's mixed audio (L16 PCM) when a + * conference listen fork is started (see monitor.ts) + * + * Full reference app: https://github.com/jambonz/room-monitor + */ + +const envVars = { + ROOM_NAME: { + type: 'string' as const, + description: 'Conference room inbound callers join', + default: 'support-room', + }, +}; + +const server = http.createServer(); +const makeService = createEndpoint({ server, port: 3000, envVars }); + +const room = (session: Session): string => + (session.data.env_vars as Record | undefined)?.ROOM_NAME || 'support-room'; + +/* -- a normal caller ------------------------------------------------------ */ +makeService({ path: '/caller' }).on('session:new', (session) => { + session + .answer() + .say({ text: 'Connecting you now.' }) + .conference({ + name: room(session), + startConferenceOnEnter: true, + endConferenceOnExit: false, + }) + .send(); +}); + +/* -- an agent: tagged, so coaching reaches them ---------------------------- */ +makeService({ path: '/agent' }).on('session:new', (session) => { + session + .answer() + .conference({ + name: room(session), + memberTag: 'agent', // <- the coach target; also drives supervision UIs + startConferenceOnEnter: true, + endConferenceOnExit: false, + }) + .send(); + + // Tags are dynamic: promote/demote a live member without re-joining, e.g. + // session.injectCommand('conf:participant-action', { action: 'untag' }); + // session.injectCommand('conf:participant-action', { action: 'tag', tag: 'agent' }); +}); + +/* -- the supervisor: joins silent; modes switch mid-call ------------------- */ +makeService({ path: '/supervisor' }).on('session:new', (session) => { + console.log(`supervisor leg ${session.callSid} — use this call_sid with monitor.ts`); + session + .answer() + .conference({ + name: room(session), + joinMuted: true, // hears everything, heard by no one + memberTag: 'supervisor', // lets tooling filter this leg out of counts + startConferenceOnEnter: false, // never create/destroy the room being watched + endConferenceOnExit: false, + actionHook: '/conf-done', + }) + .send(); + + session.on('/conf-done', () => { + session.hangup().reply(); + }); +}); + +/* -- the audio fork sink: the room mix arrives here ------------------------ */ +makeService.audio({ path: '/fork' }).on('connection', (stream: AudioStream) => { + // the metadata you passed when starting the fork arrives verbatim as the + // first text frame — make it self-describing (room, sampleRate) + console.log('fork connected:', stream.metadata); + + let bytes = 0; + stream.on('audio', (pcm: Buffer) => { + bytes += pcm.length; + // feed your STT / AI / recorder here — this is raw L16 PCM of the room + // mix. Note: coached (whispered) audio is never present in this stream. + }); + stream.on('close', () => console.log(`fork closed after ${bytes} bytes`)); +}); From d71fe6edd309f1f5fcab68a044054d16b1923ff6 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Thu, 2 Jul 2026 08:51:11 -0400 Subject: [PATCH 2/2] chore: bump @jambonz/schema to ^0.3.19 Co-Authored-By: Claude Opus 4.8 --- typescript/package-lock.json | 8 ++++---- typescript/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 182a40d..31a7cca 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.2", "license": "MIT", "dependencies": { - "@jambonz/schema": "^0.3.18", + "@jambonz/schema": "^0.3.19", "ajv": "^8.17.1", "ws": "^8.18.0" }, @@ -582,9 +582,9 @@ } }, "node_modules/@jambonz/schema": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.18.tgz", - "integrity": "sha512-N6wS1hWoVXaRksAOl6Yf6OnhReelrHA8+gdMCCwwZczDYBrpxEoRI7/WcZ8rgMH5KF8006Zoo1yw0noHdcpnSw==", + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.19.tgz", + "integrity": "sha512-pKhRhAT9xY4ZztHIo/Ras92lmt0oNpeOj3M5QSxMnqUz4Dgzky47ZZMPjJ0xpnt2fL4QOh6vu2iKyyCaaNWuPQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/typescript/package.json b/typescript/package.json index ba40a6d..75cf713 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -98,7 +98,7 @@ "postpublish": "npm run clean-docs" }, "dependencies": { - "@jambonz/schema": "^0.3.18", + "@jambonz/schema": "^0.3.19", "ajv": "^8.17.1", "ws": "^8.18.0" },