Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions examples/conference-supervision/README.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions examples/conference-supervision/monitor.ts
Original file line number Diff line number Diff line change
@@ -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 <supervisor_call_sid> [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 <supervisor_call_sid> [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');
100 changes: 100 additions & 0 deletions examples/conference-supervision/ws-app.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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`));
});
8 changes: 4 additions & 4 deletions typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down