From 14bffa2ca915988214b3e2d3f5c56ff2ad0fdf8c Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Fri, 19 Jun 2026 10:57:33 -0400 Subject: [PATCH 1/5] =?UTF-8?q?examples:=20Rooms=20=E2=80=94=20nested=20li?= =?UTF-8?q?sten,=20move=20listen/s2s=20into=20a=20Room,=20room=20say/play?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add five WebSocket examples under examples/ demonstrating Rooms: - room-with-listen-stream: a Room with a nested bidirectional listen stream - listen-then-room: 1:1 listen, then move caller + stream into a Room - s2s-move-to-room: Ultravox s2s, then move caller + agent into a Room - room-say: injectSay a one-shot announcement heard by the whole Room - room-play-tone: injectPlay a tone heard by the whole Room Bump @jambonz/schema ^0.3.8 -> ^0.3.14 so the `room` verb validates (the examples use .room(); 0.3.14 adds the room verb). llm-vendors.generated.ts header re-stamped to 0.3.14 (vendor list unchanged). The move examples issue a room-native LCC command (move-to-room / room field), supported by the matching feature-server change. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/listen-then-room/ws-app.ts | 75 +++++++++++++++++ examples/room-play-tone/ws-app.ts | 63 ++++++++++++++ examples/room-say/ws-app.ts | 67 +++++++++++++++ examples/room-with-listen-stream/ws-app.ts | 62 ++++++++++++++ examples/s2s-move-to-room/ws-app.ts | 82 +++++++++++++++++++ typescript/package-lock.json | 8 +- typescript/package.json | 2 +- typescript/src/types/llm-vendors.generated.ts | 2 +- 8 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 examples/listen-then-room/ws-app.ts create mode 100644 examples/room-play-tone/ws-app.ts create mode 100644 examples/room-say/ws-app.ts create mode 100644 examples/room-with-listen-stream/ws-app.ts create mode 100644 examples/s2s-move-to-room/ws-app.ts diff --git a/examples/listen-then-room/ws-app.ts b/examples/listen-then-room/ws-app.ts new file mode 100644 index 0000000..4ac922d --- /dev/null +++ b/examples/listen-then-room/ws-app.ts @@ -0,0 +1,75 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Start a 1:1 listen stream, then move the caller AND the live stream into a Room + * — without dropping the stream's WebSocket. + * + * The caller is answered into a bidirectional `listen` (1:1 with the WS endpoint). + * MOVE_DELAY_MS later we issue one live-call-control command: + * listen:status { listen_status: 'move-to-room', room } + * jambonz joins the caller into the Room (creating it if needed) and re-homes the + * existing listen fork into the Room as a member — the same socket keeps streaming, + * now mixed for everyone in the Room. + * + * The listen verb MUST carry an `id` to be a managed (movable) stream. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + LISTEN_WS_URL: { type: 'string', description: 'WebSocket URL for the listen stream', required: true }, + ROOM_NAME: { type: 'string', description: 'Room to move into', default: 'demo-room' }, + SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the listen fork', default: 8000 }, + MOVE_DELAY_MS: { type: 'number', description: 'ms after answer before moving into the Room', default: 10000 }, + }, +}); + +const svc = makeService({ path: '/listen-then-room' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const listenUrl = env.LISTEN_WS_URL; + const room = env.ROOM_NAME || 'demo-room'; + const sampleRate = parseInt(env.SAMPLE_RATE ?? '8000', 10); + const moveDelayMs = parseInt(env.MOVE_DELAY_MS ?? '10000', 10); + + console.log(`Incoming call ${session.callSid} -> 1:1 listen, moving into Room '${room}' in ${moveDelayMs}ms`); + if (!listenUrl) { + console.error('LISTEN_WS_URL is not configured as an application environment variable'); + return; + } + + let moved = false; + const moveToRoom = () => { + if (moved) return; + moved = true; + console.log(`>>> moving caller + listen stream into Room '${room}' (WebSocket preserved)`); + // server-side this bundles the caller-join and adopts the listen fork as a + // Room member. + session.injectCommand('listen:status', { listen_status: 'move-to-room', room }); + }; + + session + .on('/listen-done', () => session.reply()) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + // the fork connects well within the delay, so it exists by the time we move + setTimeout(moveToRoom, Math.max(0, moveDelayMs)); + + session + .answer() + .listen({ + id: 'caller-listen', // managed => movable + url: listenUrl, + sampleRate, + bidirectionalAudio: { enabled: true, streaming: true, sampleRate }, + actionHook: '/listen-done', + }) + .send(); +}); + +console.log('listen-then-room listening on port 3000 (path /listen-then-room)'); diff --git a/examples/room-play-tone/ws-app.ts b/examples/room-play-tone/ws-app.ts new file mode 100644 index 0000000..6aba65d --- /dev/null +++ b/examples/room-play-tone/ws-app.ts @@ -0,0 +1,63 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Play a tone (or any file/URL) into a Room, heard by every member, mid-call. + * + * The caller joins a Room. PLAY_DELAY_MS after joining, we call + * session.injectPlay({...}) — the audio is mixed into the Room and heard by all + * members. The optional `id` is echoed back on the play-start / play-done events. + * + * The room verb subscribes to the play lifecycle via statusEvents + statusHook. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, + PLAY_URL: { type: 'string', description: 'audio to play into the Room (file/http url or tone://)', default: 'tone://?freq=880&duration=400' }, + PLAY_DELAY_MS: { type: 'number', description: 'ms after joining before playing', default: 5000 }, + }, +}); + +const svc = makeService({ path: '/room-play-tone' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const room = env.ROOM_NAME || 'demo-room'; + const url = env.PLAY_URL || 'tone://?freq=880&duration=400'; + const playDelayMs = parseInt(env.PLAY_DELAY_MS ?? '5000', 10); + + console.log(`Incoming call ${session.callSid} -> Room '${room}', playing a tone in ${playDelayMs}ms`); + + let played = false; + session + .on('/room-status', (evt: Record) => { + console.log(`room status: ${evt?.event}`, { playId: evt?.play_id, id: evt?.id, reason: evt?.reason }); + // play once the caller has joined the Room + if ((evt?.event === 'join' || evt?.event === 'start') && !played) { + played = true; + setTimeout(() => { + console.log('>>> injectPlay into the Room'); + session.injectPlay({ url, id: 'tone' }); + }, Math.max(0, playDelayMs)); + } + }) + .on('/room-done', () => session.reply()) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .room({ + name: room, + actionHook: '/room-done', + statusHook: '/room-status', + statusEvents: ['start', 'end', 'join', 'leave', 'play-start', 'play-done'], + }) + .send(); +}); + +console.log('room-play-tone listening on port 3000 (path /room-play-tone)'); diff --git a/examples/room-say/ws-app.ts b/examples/room-say/ws-app.ts new file mode 100644 index 0000000..9708e7d --- /dev/null +++ b/examples/room-say/ws-app.ts @@ -0,0 +1,67 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Speak TTS into a Room, heard by every member, mid-call. + * + * The caller joins a Room. SAY_DELAY_MS after joining, we call + * session.injectSay({...}) — a one-shot announcement synthesized into the Room and + * heard by all members (not just the caller). The optional `id` is echoed back on + * the say-start / say-done events so you can correlate them. + * + * The room verb subscribes to the say lifecycle via statusEvents + statusHook; + * say-start fires when audio begins, say-done when it finishes. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, + SAY_TEXT: { type: 'string', description: 'text to announce into the Room', default: 'Welcome — this announcement is heard by everyone in the room.' }, + SAY_VENDOR: { type: 'string', description: 'TTS vendor (optional; account default if unset)', required: false }, + SAY_DELAY_MS: { type: 'number', description: 'ms after joining before the announcement', default: 5000 }, + }, +}); + +const svc = makeService({ path: '/room-say' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const room = env.ROOM_NAME || 'demo-room'; + const text = env.SAY_TEXT || 'Welcome — this announcement is heard by everyone in the room.'; + const synthesizer = env.SAY_VENDOR ? { vendor: env.SAY_VENDOR } : undefined; + const sayDelayMs = parseInt(env.SAY_DELAY_MS ?? '5000', 10); + + console.log(`Incoming call ${session.callSid} -> Room '${room}', announcing in ${sayDelayMs}ms`); + + let announced = false; + session + .on('/room-status', (evt: Record) => { + console.log(`room status: ${evt?.event}`, { sayId: evt?.say_id, id: evt?.id, reason: evt?.reason }); + // announce once the caller has joined the Room + if ((evt?.event === 'join' || evt?.event === 'start') && !announced) { + announced = true; + setTimeout(() => { + console.log('>>> injectSay into the Room'); + session.injectSay({ text, id: 'announcement', ...(synthesizer && { synthesizer }) }); + }, Math.max(0, sayDelayMs)); + } + }) + .on('/room-done', () => session.reply()) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .room({ + name: room, + actionHook: '/room-done', + statusHook: '/room-status', + statusEvents: ['start', 'end', 'join', 'leave', 'say-start', 'say-done'], + }) + .send(); +}); + +console.log('room-say listening on port 3000 (path /room-say)'); diff --git a/examples/room-with-listen-stream/ws-app.ts b/examples/room-with-listen-stream/ws-app.ts new file mode 100644 index 0000000..5804b39 --- /dev/null +++ b/examples/room-with-listen-stream/ws-app.ts @@ -0,0 +1,62 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * A Room with a nested listen stream. + * + * The caller is answered straight into a Room whose `listen` property forks the + * room's mixed audio to a WebSocket endpoint. With bidirectionalAudio enabled, + * whatever the endpoint streams back is mixed into the room and heard by every + * member — so the listen socket is effectively a participant in the Room. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + LISTEN_WS_URL: { type: 'string', description: 'WebSocket URL to stream the room audio to', required: true }, + ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, + SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the listen fork', default: 8000 }, + }, +}); + +const svc = makeService({ path: '/room-with-listen' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const listenUrl = env.LISTEN_WS_URL; + const room = env.ROOM_NAME || 'demo-room'; + const sampleRate = parseInt(env.SAMPLE_RATE ?? '8000', 10); + + console.log(`Incoming call ${session.callSid} -> Room '${room}' with a nested listen stream`); + if (!listenUrl) { + console.error('LISTEN_WS_URL is not configured as an application environment variable'); + return; + } + + session + .on('/room-done', () => session.reply()) // room verb completed (caller left) + .on('/listen-event', (evt: Record) => console.log('listen event:', evt?.type)) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .room({ + name: room, + beep: true, + actionHook: '/room-done', + // nested listen: fork the room's audio to LISTEN_WS_URL; audio streamed back + // is mixed into the room (bidirectional). + listen: { + url: listenUrl, + sampleRate, + bidirectionalAudio: { enabled: true, streaming: true, sampleRate }, + actionHook: '/listen-event', + }, + }) + .send(); +}); + +console.log('room-with-listen-stream listening on port 3000 (path /room-with-listen)'); diff --git a/examples/s2s-move-to-room/ws-app.ts b/examples/s2s-move-to-room/ws-app.ts new file mode 100644 index 0000000..4fe8c35 --- /dev/null +++ b/examples/s2s-move-to-room/ws-app.ts @@ -0,0 +1,82 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Connect a caller to an Ultravox speech-to-speech agent, then move BOTH the + * caller and the agent into a Room — keeping the same conversation and the vendor + * WebSocket alive. + * + * The caller is answered into a 1:1 Ultravox s2s session. Once the agent is live + * (first event), we wait MOVE_DELAY_MS and issue one live-call-control command: + * llm:status { llm_status: 'move-to-room', room } + * jambonz joins the caller into the Room (created if needed) and re-homes the + * agent's s2s engine into the Room as a member — no reconnect, the agent now + * hears (and is heard by) the whole Room. + * + * Set MOVE_DELAY_MS=0 to land the caller + agent in the Room immediately. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + ULTRAVOX_API_KEY: { type: 'string', description: 'Ultravox API key', required: true, obscure: true }, + ULTRAVOX_AGENT_ID: { type: 'string', description: 'Ultravox agent id', required: true }, + ROOM_NAME: { type: 'string', description: 'Room to move into', default: 'agent-room' }, + MOVE_DELAY_MS: { type: 'number', description: 'ms after the agent connects before moving into a Room', default: 15000 }, + }, +}); + +const svc = makeService({ path: '/s2s-move-to-room' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const apiKey = env.ULTRAVOX_API_KEY; + const agentId = env.ULTRAVOX_AGENT_ID; + const room = env.ROOM_NAME || 'agent-room'; + const moveDelayMs = parseInt(env.MOVE_DELAY_MS ?? '15000', 10); + + console.log(`Incoming call ${session.callSid} -> Ultravox s2s; moving into Room '${room}' ${moveDelayMs}ms after connect`); + if (!apiKey || !agentId) { + console.error('ULTRAVOX_API_KEY and ULTRAVOX_AGENT_ID must be configured as application environment variables'); + session.say({ text: 'Configuration error.' }).hangup().send(); + return; + } + + let armed = false; + let moved = false; + const moveToRoom = () => { + if (moved) return; + moved = true; + console.log(`>>> moving caller + agent into Room '${room}' (vendor WebSocket preserved)`); + session.injectCommand('llm:status', { llm_status: 'move-to-room', room }); + }; + + session + .on('/event', () => { + // the first agent event fires only after the vendor WebSocket connects, so + // the s2s engine exists server-side; arm the move timer from here. + if (!armed) { + armed = true; + setTimeout(moveToRoom, Math.max(0, moveDelayMs)); + } + }) + .on('/final', () => session.reply()) // s2s (llm) verb completed + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .ultravox_s2s({ + auth: { apiKey, agent_id: agentId }, + actionHook: '/final', + eventHook: '/event', + events: ['all'], + llmOptions: {}, + }) + .hangup() + .send(); +}); + +console.log('s2s-move-to-room listening on port 3000 (path /s2s-move-to-room)'); diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 791275a..56133f7 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "@jambonz/schema": "^0.3.8", + "@jambonz/schema": "^0.3.14", "ajv": "^8.17.1", "ws": "^8.18.0" }, @@ -582,9 +582,9 @@ } }, "node_modules/@jambonz/schema": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.8.tgz", - "integrity": "sha512-NOLRlzLP8x4OhCRQ9sE2jXsg6khqPZo7ehaQ110dnox72Dxi08tt3eOv9q2h8nOF3bm0yyq3rk91/Qptyzxzvg==", + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.14.tgz", + "integrity": "sha512-D+RNTOvFXZP1tYFKv5BcouroEc4Kka2drbBHREJ6QJc/tRt41XY2ni+MoYDl4KUfZieBkyANzwQrT2uT0wbdoQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/typescript/package.json b/typescript/package.json index fbfc0ea..05452eb 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -98,7 +98,7 @@ "postpublish": "npm run clean-docs" }, "dependencies": { - "@jambonz/schema": "^0.3.8", + "@jambonz/schema": "^0.3.14", "ajv": "^8.17.1", "ws": "^8.18.0" }, diff --git a/typescript/src/types/llm-vendors.generated.ts b/typescript/src/types/llm-vendors.generated.ts index 60afb0f..4591607 100644 --- a/typescript/src/types/llm-vendors.generated.ts +++ b/typescript/src/types/llm-vendors.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED — DO NOT EDIT BY HAND. -// Source of truth: @jambonz/schema@0.3.8 verbs/agent.schema.json (llm.vendor.enum) +// Source of truth: @jambonz/schema@0.3.14 verbs/agent.schema.json (llm.vendor.enum) // Regenerate with: npm run gen:types // // This file derives the LLM vendor list from the JSON schema so the SDK's From d88268f5806d5fc558ac18b0487d393daec461ba Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Fri, 19 Jun 2026 11:10:52 -0400 Subject: [PATCH 2/5] examples/docs: prefer room + stream synonyms over conference + listen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the room and stream verbs everywhere (both kept for back-compat): - rename listen-then-room -> stream-then-room and switch to .stream() + stream:status / move-to-room - rename listen-record -> stream-record and switch .listen() -> .stream() - README + AGENTS verb catalog: add .room(), lead with room/stream, note that conference/listen remain supported synonyms - README + AGENTS audio section + example lists: use stream (not listen), list the new Rooms examples The room verb's nested audio-fork property stays `listen` (room-with-listen-stream) — that's the actual property name; there is no stream synonym at the property level. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 35 ++++++++------ README.md | 13 +++-- .../{listen-record => stream-record}/app.ts | 4 +- .../ws-app.ts | 47 +++++++++---------- 4 files changed, 56 insertions(+), 43 deletions(-) rename examples/{listen-record => stream-record}/app.ts (84%) rename examples/{listen-then-room => stream-then-room}/ws-app.ts (52%) diff --git a/AGENTS.md b/AGENTS.md index 7cfa81e..9623661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,9 @@ console.log('Speech echo WebSocket app listening on port 3000'); Both `WebhookResponse` and `Session` support the same chainable verb methods: -`.say(opts)` `.play(opts)` `.gather(opts)` `.dial(opts)` `.llm(opts)` `.s2s(opts)` `.openai_s2s(opts)` `.google_s2s(opts)` `.elevenlabs_s2s(opts)` `.deepgram_s2s(opts)` `.ultravox_s2s(opts)` `.dialogflow(opts)` `.conference(opts)` `.enqueue(opts)` `.dequeue(opts)` `.hangup()` `.pause(opts)` `.redirect(opts)` `.config(opts)` `.tag(opts)` `.dtmf(opts)` `.listen(opts)` `.transcribe(opts)` `.message(opts)` `.stream(opts)` `.agent(opts)` `.dub(opts)` `.alert(opts)` `.answer(opts)` `.leave()` `.sipDecline(opts)` `.sipRefer(opts)` `.sipRequest(opts)` +`.say(opts)` `.play(opts)` `.gather(opts)` `.dial(opts)` `.llm(opts)` `.s2s(opts)` `.openai_s2s(opts)` `.google_s2s(opts)` `.elevenlabs_s2s(opts)` `.deepgram_s2s(opts)` `.ultravox_s2s(opts)` `.dialogflow(opts)` `.room(opts)` `.enqueue(opts)` `.dequeue(opts)` `.hangup()` `.pause(opts)` `.redirect(opts)` `.config(opts)` `.tag(opts)` `.dtmf(opts)` `.stream(opts)` `.transcribe(opts)` `.message(opts)` `.agent(opts)` `.dub(opts)` `.alert(opts)` `.answer(opts)` `.leave()` `.sipDecline(opts)` `.sipRefer(opts)` `.sipRequest(opts)` + +Prefer `.room(opts)` and `.stream(opts)`. `.conference(opts)` and `.listen(opts)` remain as backward-compatible synonyms (same options), but new apps should use `room`/`stream`. All methods accept the same options as the corresponding verb JSON Schema. Methods are chainable — they return `this`. @@ -415,9 +417,9 @@ session.on('close', (code, reason) => { /* connection closed */ }); session.on('error', (err) => { /* error */ }); ``` -## Audio WebSocket (Listen/Stream) +## Audio WebSocket (Stream/Listen) -The `listen` and `stream` verbs open a separate WebSocket connection from jambonz to your application, carrying raw audio. This is independent of the control WebSocket (`ws.jambonz.org`) — it uses the `audio.drachtio.org` subprotocol. +The `stream` verb (and its backward-compatible synonym `listen`) opens a separate WebSocket connection from jambonz to your application, carrying raw audio. This is independent of the control WebSocket (`ws.jambonz.org`) — it uses the `audio.drachtio.org` subprotocol. ### Receiving Audio in the Same Application @@ -433,14 +435,14 @@ const makeService = createEndpoint({ server, port: 3000 }); // Control pipe — handles call sessions const svc = makeService({ path: '/' }); -// Audio pipe — receives listen/stream audio +// Audio pipe — receives stream audio const audioSvc = makeService.audio({ path: '/audio-stream' }); svc.on('session:new', (session) => { session .answer() .say({ text: 'Recording your audio.' }) - .listen({ + .stream({ url: '/audio-stream', // relative path — jambonz connects back to same server sampleRate: 16000, mixType: 'mono', @@ -472,7 +474,7 @@ The `stream` object in the `connection` event is an `AudioStream` instance: **Events**: - `audio` — L16 PCM binary frame (`Buffer`) -- `dtmf` — `{digit, duration}` (only if `passDtmf: true` on listen verb) +- `dtmf` — `{digit, duration}` (only if `passDtmf: true` on stream verb) - `playDone` — `{id}` (after non-streaming playAudio completes) - `mark` — `{name, event}` where event is `'playout'` or `'cleared'` - `close` — `(code, reason)` @@ -480,7 +482,7 @@ The `stream` object in the `connection` event is an `AudioStream` instance: ### Sending Audio Back (Bidirectional) -The listen verb supports bidirectional audio. There are two modes, controlled by the `bidirectionalAudio.streaming` option on the listen verb. +The stream verb supports bidirectional audio. There are two modes, controlled by the `bidirectionalAudio.streaming` option on the stream verb. **Non-streaming mode** (`streaming: false`, the default) — send complete audio clips as base64: @@ -502,7 +504,7 @@ Up to 10 playAudio commands can be queued simultaneously. **Streaming mode** (`streaming: true`) — send raw binary PCM frames directly: ```typescript -// In the listen verb config: +// In the stream verb config: // bidirectionalAudio: { enabled: true, streaming: true, sampleRate: 16000 } stream.on('audio', (pcm) => { @@ -513,16 +515,16 @@ stream.on('audio', (pcm) => { ### Marks (Synchronization Markers) -Marks let you track when streamed audio has been played out to the caller. They work **only with bidirectional streaming mode** — you must enable `bidirectionalAudio: { enabled: true, streaming: true }` on the listen verb. +Marks let you track when streamed audio has been played out to the caller. They work **only with bidirectional streaming mode** — you must enable `bidirectionalAudio: { enabled: true, streaming: true }` on the stream verb. The pattern is: stream audio via `sendAudio()`, then send a mark. When all the audio sent before the mark finishes playing out, jambonz sends back a mark event with `event: 'playout'`. This is how you know the caller has heard a specific chunk of audio. ```typescript -// Listen verb must enable bidirectional streaming for marks to work +// Stream verb must enable bidirectional streaming for marks to work session - .listen({ + .stream({ url: '/audio', - actionHook: '/listen-done', + actionHook: '/stream-done', bidirectionalAudio: { enabled: true, streaming: true, @@ -557,7 +559,7 @@ audioSvc.on('connection', (stream) => { ```typescript stream.killAudio(); // Stop playback, flush buffer -stream.disconnect(); // Close connection, end listen verb +stream.disconnect(); // Close connection, end stream verb stream.sendMark('sync-pt'); // Insert synchronization marker stream.clearMarks(); // Clear all pending markers stream.close(); // Close the WebSocket @@ -710,7 +712,7 @@ Complete working examples are in the `examples/` directory: - **echo** — Speech echo using gather with actionHook pattern (webhook + WebSocket). The canonical example for understanding actionHook event handling. - **ivr-menu** — Interactive menu with speech and DTMF input (webhook) - **dial** — Simple outbound dial to a phone number (webhook) -- **listen-record** — Record audio using the listen verb to stream to a WebSocket (webhook) +- **stream-record** — Record audio using the stream verb to stream to a WebSocket (webhook) - **voice-agent** — LLM-powered conversational AI with tool calls (webhook + WebSocket) - **openai-realtime** — OpenAI Realtime API voice agent with function calling (WebSocket) - **deepgram-voice-agent** — Deepgram Voice Agent API with function calling (WebSocket) @@ -719,3 +721,8 @@ Complete working examples are in the `examples/` directory: - **queue-with-hold** — Call queue with hold music and agent dequeue (webhook + WebSocket) - **call-recording** — Mid-call recording control via REST API and inject commands (webhook + WebSocket) - **realtime-translator** — Bridges two parties with real-time speech translation using STT, Google Translate, and TTS dub tracks. Multi-file example with `src/routes/` structure (WebSocket) +- **room-with-listen-stream** — A Room with a nested bidirectional audio stream forked to a WebSocket (WebSocket) +- **stream-then-room** — 1:1 stream, then move the caller + stream into a Room mid-call (WebSocket) +- **s2s-move-to-room** — Ultravox s2s, then move the caller + agent into a Room mid-call (WebSocket) +- **room-say** — injectSay a one-shot TTS announcement heard by the whole Room (WebSocket) +- **room-play-tone** — injectPlay a tone heard by the whole Room (WebSocket) diff --git a/README.md b/README.md index 35bce92..2da49a4 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ const audioSvc = makeService.audio({ path: '/audio-stream' }); svc.on('session:new', (session) => { session .say({ text: 'Listening...' }) - .listen({ + .stream({ url: '/audio-stream', // relative path — jambonz connects back to same server sampleRate: 8000, bidirectionalAudio: { @@ -369,7 +369,9 @@ import { JambonzClient } from '@jambonz/sdk/client'; Both `WebhookResponse` and WebSocket `Session` support the same chainable verb methods: -`.say()` `.play()` `.gather()` `.dial()` `.llm()` `.conference()` `.enqueue()` `.dequeue()` `.hangup()` `.pause()` `.redirect()` `.config()` `.tag()` `.dtmf()` `.listen()` `.transcribe()` `.message()` `.stream()` `.agent()` `.dub()` `.alert()` `.answer()` `.leave()` `.sipDecline()` `.sipRefer()` `.sipRequest()` +`.say()` `.play()` `.gather()` `.dial()` `.llm()` `.room()` `.enqueue()` `.dequeue()` `.hangup()` `.pause()` `.redirect()` `.config()` `.tag()` `.dtmf()` `.stream()` `.transcribe()` `.message()` `.agent()` `.dub()` `.alert()` `.answer()` `.leave()` `.sipDecline()` `.sipRefer()` `.sipRequest()` + +> Prefer `.room()` and `.stream()`. `.conference()` and `.listen()` are still supported as synonyms for backward compatibility. All methods accept the same options as the corresponding [verb JSON schemas](schema/verbs/) and are chainable. @@ -427,13 +429,18 @@ See the [examples/](examples/) directory: | [echo](examples/echo/) | Webhook + WS | Speech echo using gather with actionHook | | [ivr-menu](examples/ivr-menu/) | Webhook | Interactive menu with speech and DTMF | | [dial](examples/dial/) | Webhook | Outbound dial to a phone number | -| [listen-record](examples/listen-record/) | Webhook | Record audio via WebSocket stream | +| [stream-record](examples/stream-record/) | Webhook | Record audio via WebSocket stream | | [voice-agent](examples/voice-agent/) | Webhook + WS | LLM-powered conversational AI with tool calls | | [openai-realtime](examples/openai-realtime/) | WebSocket | OpenAI Realtime API voice agent | | [deepgram-voice-agent](examples/deepgram-voice-agent/) | WebSocket | Deepgram Voice Agent API | | [llm-streaming](examples/llm-streaming/) | WebSocket | Anthropic LLM with TTS streaming and barge-in | | [queue-with-hold](examples/queue-with-hold/) | Webhook + WS | Call queue with hold music | | [call-recording](examples/call-recording/) | Webhook + WS | Mid-call recording control | +| [room-with-listen-stream](examples/room-with-listen-stream/) | WebSocket | A Room with a nested bidirectional audio stream | +| [stream-then-room](examples/stream-then-room/) | WebSocket | 1:1 stream, then move caller + stream into a Room | +| [s2s-move-to-room](examples/s2s-move-to-room/) | WebSocket | Ultravox s2s, then move caller + agent into a Room | +| [room-say](examples/room-say/) | WebSocket | injectSay a one-shot announcement heard by the whole Room | +| [room-play-tone](examples/room-play-tone/) | WebSocket | injectPlay a tone heard by the whole Room | ## Publishing to npm diff --git a/examples/listen-record/app.ts b/examples/stream-record/app.ts similarity index 84% rename from examples/listen-record/app.ts rename to examples/stream-record/app.ts index 8bdcff0..7706b25 100644 --- a/examples/listen-record/app.ts +++ b/examples/stream-record/app.ts @@ -14,9 +14,9 @@ app.post('/record', (_req, res) => { .say({ text: 'Hi there. Please leave a message, and we will get back to you shortly.', }) - .listen({ url: wsUrl }); + .stream({ url: wsUrl }); res.json(jambonz); }); -app.listen(3000, () => console.log('Listen-record webhook app listening on port 3000')); +app.listen(3000, () => console.log('Stream-record webhook app listening on port 3000')); diff --git a/examples/listen-then-room/ws-app.ts b/examples/stream-then-room/ws-app.ts similarity index 52% rename from examples/listen-then-room/ws-app.ts rename to examples/stream-then-room/ws-app.ts index 4ac922d..0a38b52 100644 --- a/examples/listen-then-room/ws-app.ts +++ b/examples/stream-then-room/ws-app.ts @@ -2,17 +2,17 @@ import http from 'http'; import { createEndpoint } from '@jambonz/sdk/websocket'; /* - * Start a 1:1 listen stream, then move the caller AND the live stream into a Room + * Start a 1:1 audio stream, then move the caller AND the live stream into a Room * — without dropping the stream's WebSocket. * - * The caller is answered into a bidirectional `listen` (1:1 with the WS endpoint). + * The caller is answered into a bidirectional `stream` (1:1 with the WS endpoint). * MOVE_DELAY_MS later we issue one live-call-control command: - * listen:status { listen_status: 'move-to-room', room } + * stream:status { stream_status: 'move-to-room', room } * jambonz joins the caller into the Room (creating it if needed) and re-homes the - * existing listen fork into the Room as a member — the same socket keeps streaming, - * now mixed for everyone in the Room. + * existing stream into the Room as a member — the same socket keeps streaming, now + * mixed for everyone in the Room. * - * The listen verb MUST carry an `id` to be a managed (movable) stream. + * The stream verb MUST carry an `id` to be a managed (movable) stream. */ const server = http.createServer(); @@ -20,25 +20,25 @@ const makeService = createEndpoint({ server, port: 3000, envVars: { - LISTEN_WS_URL: { type: 'string', description: 'WebSocket URL for the listen stream', required: true }, + STREAM_WS_URL: { type: 'string', description: 'WebSocket URL for the audio stream', required: true }, ROOM_NAME: { type: 'string', description: 'Room to move into', default: 'demo-room' }, - SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the listen fork', default: 8000 }, + SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the stream', default: 8000 }, MOVE_DELAY_MS: { type: 'number', description: 'ms after answer before moving into the Room', default: 10000 }, }, }); -const svc = makeService({ path: '/listen-then-room' }); +const svc = makeService({ path: '/stream-then-room' }); svc.on('session:new', (session) => { const env = session.data.env_vars || {}; - const listenUrl = env.LISTEN_WS_URL; + const streamUrl = env.STREAM_WS_URL; const room = env.ROOM_NAME || 'demo-room'; const sampleRate = parseInt(env.SAMPLE_RATE ?? '8000', 10); const moveDelayMs = parseInt(env.MOVE_DELAY_MS ?? '10000', 10); - console.log(`Incoming call ${session.callSid} -> 1:1 listen, moving into Room '${room}' in ${moveDelayMs}ms`); - if (!listenUrl) { - console.error('LISTEN_WS_URL is not configured as an application environment variable'); + console.log(`Incoming call ${session.callSid} -> 1:1 stream, moving into Room '${room}' in ${moveDelayMs}ms`); + if (!streamUrl) { + console.error('STREAM_WS_URL is not configured as an application environment variable'); return; } @@ -46,30 +46,29 @@ svc.on('session:new', (session) => { const moveToRoom = () => { if (moved) return; moved = true; - console.log(`>>> moving caller + listen stream into Room '${room}' (WebSocket preserved)`); - // server-side this bundles the caller-join and adopts the listen fork as a - // Room member. - session.injectCommand('listen:status', { listen_status: 'move-to-room', room }); + console.log(`>>> moving caller + stream into Room '${room}' (WebSocket preserved)`); + // server-side this bundles the caller-join and adopts the stream as a Room member. + session.injectCommand('stream:status', { stream_status: 'move-to-room', room }); }; session - .on('/listen-done', () => session.reply()) + .on('/stream-done', () => session.reply()) .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) .on('error', (err: Error) => console.error('session error:', err)); - // the fork connects well within the delay, so it exists by the time we move + // the stream connects well within the delay, so it exists by the time we move setTimeout(moveToRoom, Math.max(0, moveDelayMs)); session .answer() - .listen({ - id: 'caller-listen', // managed => movable - url: listenUrl, + .stream({ + id: 'caller-stream', // managed => movable + url: streamUrl, sampleRate, bidirectionalAudio: { enabled: true, streaming: true, sampleRate }, - actionHook: '/listen-done', + actionHook: '/stream-done', }) .send(); }); -console.log('listen-then-room listening on port 3000 (path /listen-then-room)'); +console.log('stream-then-room listening on port 3000 (path /stream-then-room)'); From 32a8e90e55307846001928f4bfd23438fdee0b9a Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Fri, 19 Jun 2026 12:15:24 -0400 Subject: [PATCH 3/5] feat(sdk): nested room stream synonym + room-with-stream example Bump @jambonz/schema ^0.3.14 -> ^0.3.15 (adds the conference/room 'stream' nested property). Add ConferenceVerb.stream?: Omit (RoomVerb inherits) as the preferred synonym for the nested 'listen' fork. Rename the example room-with-listen-stream -> room-with-stream and use the nested stream: property, so it's fully stream-native. README/AGENTS refs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- README.md | 2 +- .../ws-app.ts | 34 ++++++++++--------- typescript/package-lock.json | 8 ++--- typescript/package.json | 2 +- typescript/src/types/verbs.ts | 4 ++- 6 files changed, 28 insertions(+), 24 deletions(-) rename examples/{room-with-listen-stream => room-with-stream}/ws-app.ts (62%) diff --git a/AGENTS.md b/AGENTS.md index 9623661..3e008ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -721,7 +721,7 @@ Complete working examples are in the `examples/` directory: - **queue-with-hold** — Call queue with hold music and agent dequeue (webhook + WebSocket) - **call-recording** — Mid-call recording control via REST API and inject commands (webhook + WebSocket) - **realtime-translator** — Bridges two parties with real-time speech translation using STT, Google Translate, and TTS dub tracks. Multi-file example with `src/routes/` structure (WebSocket) -- **room-with-listen-stream** — A Room with a nested bidirectional audio stream forked to a WebSocket (WebSocket) +- **room-with-stream** — A Room with a nested bidirectional audio stream forked to a WebSocket (WebSocket) - **stream-then-room** — 1:1 stream, then move the caller + stream into a Room mid-call (WebSocket) - **s2s-move-to-room** — Ultravox s2s, then move the caller + agent into a Room mid-call (WebSocket) - **room-say** — injectSay a one-shot TTS announcement heard by the whole Room (WebSocket) diff --git a/README.md b/README.md index 2da49a4..556de2a 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ See the [examples/](examples/) directory: | [llm-streaming](examples/llm-streaming/) | WebSocket | Anthropic LLM with TTS streaming and barge-in | | [queue-with-hold](examples/queue-with-hold/) | Webhook + WS | Call queue with hold music | | [call-recording](examples/call-recording/) | Webhook + WS | Mid-call recording control | -| [room-with-listen-stream](examples/room-with-listen-stream/) | WebSocket | A Room with a nested bidirectional audio stream | +| [room-with-stream](examples/room-with-stream/) | WebSocket | A Room with a nested bidirectional audio stream | | [stream-then-room](examples/stream-then-room/) | WebSocket | 1:1 stream, then move caller + stream into a Room | | [s2s-move-to-room](examples/s2s-move-to-room/) | WebSocket | Ultravox s2s, then move caller + agent into a Room | | [room-say](examples/room-say/) | WebSocket | injectSay a one-shot announcement heard by the whole Room | diff --git a/examples/room-with-listen-stream/ws-app.ts b/examples/room-with-stream/ws-app.ts similarity index 62% rename from examples/room-with-listen-stream/ws-app.ts rename to examples/room-with-stream/ws-app.ts index 5804b39..79189c1 100644 --- a/examples/room-with-listen-stream/ws-app.ts +++ b/examples/room-with-stream/ws-app.ts @@ -2,12 +2,14 @@ import http from 'http'; import { createEndpoint } from '@jambonz/sdk/websocket'; /* - * A Room with a nested listen stream. + * A Room with a nested audio stream. * - * The caller is answered straight into a Room whose `listen` property forks the + * The caller is answered straight into a Room whose `stream` property forks the * room's mixed audio to a WebSocket endpoint. With bidirectionalAudio enabled, * whatever the endpoint streams back is mixed into the room and heard by every - * member — so the listen socket is effectively a participant in the Room. + * member — so the stream socket is effectively a participant in the Room. + * + * (`stream` is the preferred synonym for the nested `listen` property.) */ const server = http.createServer(); @@ -15,29 +17,29 @@ const makeService = createEndpoint({ server, port: 3000, envVars: { - LISTEN_WS_URL: { type: 'string', description: 'WebSocket URL to stream the room audio to', required: true }, + STREAM_WS_URL: { type: 'string', description: 'WebSocket URL to stream the room audio to', required: true }, ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, - SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the listen fork', default: 8000 }, + SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the stream', default: 8000 }, }, }); -const svc = makeService({ path: '/room-with-listen' }); +const svc = makeService({ path: '/room-with-stream' }); svc.on('session:new', (session) => { const env = session.data.env_vars || {}; - const listenUrl = env.LISTEN_WS_URL; + const streamUrl = env.STREAM_WS_URL; const room = env.ROOM_NAME || 'demo-room'; const sampleRate = parseInt(env.SAMPLE_RATE ?? '8000', 10); - console.log(`Incoming call ${session.callSid} -> Room '${room}' with a nested listen stream`); - if (!listenUrl) { - console.error('LISTEN_WS_URL is not configured as an application environment variable'); + console.log(`Incoming call ${session.callSid} -> Room '${room}' with a nested audio stream`); + if (!streamUrl) { + console.error('STREAM_WS_URL is not configured as an application environment variable'); return; } session .on('/room-done', () => session.reply()) // room verb completed (caller left) - .on('/listen-event', (evt: Record) => console.log('listen event:', evt?.type)) + .on('/stream-event', (evt: Record) => console.log('stream event:', evt?.type)) .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) .on('error', (err: Error) => console.error('session error:', err)); @@ -47,16 +49,16 @@ svc.on('session:new', (session) => { name: room, beep: true, actionHook: '/room-done', - // nested listen: fork the room's audio to LISTEN_WS_URL; audio streamed back + // nested stream: fork the room's audio to STREAM_WS_URL; audio streamed back // is mixed into the room (bidirectional). - listen: { - url: listenUrl, + stream: { + url: streamUrl, sampleRate, bidirectionalAudio: { enabled: true, streaming: true, sampleRate }, - actionHook: '/listen-event', + actionHook: '/stream-event', }, }) .send(); }); -console.log('room-with-listen-stream listening on port 3000 (path /room-with-listen)'); +console.log('room-with-stream listening on port 3000 (path /room-with-stream)'); diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 56133f7..f82f8f3 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "@jambonz/schema": "^0.3.14", + "@jambonz/schema": "^0.3.15", "ajv": "^8.17.1", "ws": "^8.18.0" }, @@ -582,9 +582,9 @@ } }, "node_modules/@jambonz/schema": { - "version": "0.3.14", - "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.14.tgz", - "integrity": "sha512-D+RNTOvFXZP1tYFKv5BcouroEc4Kka2drbBHREJ6QJc/tRt41XY2ni+MoYDl4KUfZieBkyANzwQrT2uT0wbdoQ==", + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.15.tgz", + "integrity": "sha512-MvDVBUCUgRk+cs/Qjl6GT/LhgBdY5ze5rgSOY6QOUaWd72dBYNAFZw4au8MYXZPIWpvP9iaHTU5G4Ro5cXIFlQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/typescript/package.json b/typescript/package.json index 05452eb..56043af 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -98,7 +98,7 @@ "postpublish": "npm run clean-docs" }, "dependencies": { - "@jambonz/schema": "^0.3.14", + "@jambonz/schema": "^0.3.15", "ajv": "^8.17.1", "ws": "^8.18.0" }, diff --git a/typescript/src/types/verbs.ts b/typescript/src/types/verbs.ts index 205e6d0..f25855d 100644 --- a/typescript/src/types/verbs.ts +++ b/typescript/src/types/verbs.ts @@ -534,7 +534,9 @@ export interface ConferenceVerb { enterHook?: ActionHook; /** Conference recording config. */ record?: Record; - /** Audio streaming config. */ + /** Fork the room's mixed audio to a WebSocket. Prefer `stream`; `listen` is a synonym. */ + stream?: Omit; + /** Audio streaming config (synonym for `stream`). */ listen?: Omit; /** Distribute DTMF to all participants. */ distributeDtmf?: boolean; From fd47f2bf26711ac108205c0422c4fe8a1e696cd3 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Fri, 19 Jun 2026 12:15:46 -0400 Subject: [PATCH 4/5] chore: re-stamp llm-vendors.generated.ts header to schema 0.3.15 (vendor list unchanged) Co-Authored-By: Claude Opus 4.8 (1M context) --- typescript/src/types/llm-vendors.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/types/llm-vendors.generated.ts b/typescript/src/types/llm-vendors.generated.ts index 4591607..3b311e1 100644 --- a/typescript/src/types/llm-vendors.generated.ts +++ b/typescript/src/types/llm-vendors.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED — DO NOT EDIT BY HAND. -// Source of truth: @jambonz/schema@0.3.14 verbs/agent.schema.json (llm.vendor.enum) +// Source of truth: @jambonz/schema@0.3.15 verbs/agent.schema.json (llm.vendor.enum) // Regenerate with: npm run gen:types // // This file derives the LLM vendor list from the JSON schema so the SDK's From 3f5abc86de416f9df1d2ae9b99acd4ed75202cea Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Fri, 19 Jun 2026 12:18:24 -0400 Subject: [PATCH 5/5] fix(examples): bedrock-agent reads Tavily key from env var, not hardcoded Replace the hardcoded tvly-dev-... API key with session.data.env_vars.TAVILY_API_KEY and declare TAVILY_API_KEY (obscure: true) as an application env var. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/bedrock-agent.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/bedrock-agent.ts b/examples/bedrock-agent.ts index a9766bb..0d74b75 100644 --- a/examples/bedrock-agent.ts +++ b/examples/bedrock-agent.ts @@ -25,6 +25,11 @@ const envVars = { description: 'ElevenLabs voice id', default: 'hpp4J3VqNfWAUOO0d1Us', }, + TAVILY_API_KEY: { + type: 'string' as const, + description: 'Tavily API key for the web_search tool', + obscure: true, + }, SYSTEM_PROMPT: { type: 'string' as const, description: 'System prompt for the voice agent', @@ -136,7 +141,7 @@ function handleSession(session: Session, opts: AgentOptions) { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - api_key: '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', + api_key: session.data.env_vars?.TAVILY_API_KEY, query, max_results: 3, search_depth: 'basic',