From 0c6a4c36b05e3b0c981500b03a48643abd96d49d Mon Sep 17 00:00:00 2001 From: Hoan HL Date: Mon, 22 Jun 2026 17:30:21 +0700 Subject: [PATCH 1/2] support transfer to human api --- AGENTS.md | 2 +- typescript/package-lock.json | 26 +++++--- typescript/src/client/api.ts | 5 ++ typescript/src/types/index.ts | 5 ++ typescript/src/types/rest.ts | 4 +- typescript/src/types/verbs.ts | 92 ++++++++++++++++++++++++++++- typescript/src/verb-builder.ts | 6 ++ typescript/src/websocket/session.ts | 2 + 8 files changed, 131 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e008ed..2eb908e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ 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)` `.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)` +`.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)` `.transfer(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`. diff --git a/typescript/package-lock.json b/typescript/package-lock.json index c45dbfc..cd68fa6 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -26,6 +26,20 @@ "node": ">=18.0.0" } }, + "../../schema": { + "name": "@jambonz/schema", + "version": "0.3.15", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "debug": "^4.3.4" + }, + "devDependencies": { + "eslint": "^9.3.0", + "eslint-plugin-promise": "^6.2.0", + "globals": "^15.3.0" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -582,14 +596,8 @@ } }, "node_modules/@jambonz/schema": { - "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", - "debug": "^4.3.4" - } + "resolved": "../../schema", + "link": true }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", @@ -1481,6 +1489,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2026,6 +2035,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { diff --git a/typescript/src/client/api.ts b/typescript/src/client/api.ts index a005584..8634d90 100644 --- a/typescript/src/client/api.ts +++ b/typescript/src/client/api.ts @@ -152,6 +152,11 @@ export class CallsResource { return this.update(callSid, { agent_update: data }); } + /** Redirect the in-progress call to a packaged transfer (blind or warm). */ + async transfer(callSid: string, opts: NonNullable): Promise { + return this.update(callSid, { transfer: opts }); + } + /** Enable or disable server-side noise isolation. */ async noiseIsolation( callSid: string, diff --git a/typescript/src/types/index.ts b/typescript/src/types/index.ts index 4079073..27f4f5f 100644 --- a/typescript/src/types/index.ts +++ b/typescript/src/types/index.ts @@ -33,6 +33,7 @@ export type { DubVerb, EnqueueVerb, GatherVerb, + Handoff, HangupVerb, JambonzApp, LeaveVerb, @@ -54,6 +55,10 @@ export type { StreamVerb, TagVerb, TranscribeVerb, + TransferConfirm, + TransferDisposition, + TransferOptions, + TransferVerb, Verb, VerbName, } from './verbs.js'; diff --git a/typescript/src/types/rest.ts b/typescript/src/types/rest.ts index b9d17d6..7fdc331 100644 --- a/typescript/src/types/rest.ts +++ b/typescript/src/types/rest.ts @@ -3,7 +3,7 @@ */ import type { ActionHook } from './components.js'; -import type { SayVerb, Verb } from './verbs.js'; +import type { SayVerb, TransferVerb, Verb } from './verbs.js'; export interface CreateCallRequest { /** Application SID to handle the call. */ @@ -114,6 +114,8 @@ export interface UpdateCallRequest { }; /** Send DTMF digits. */ dtmf?: { digit: string; duration?: number }; + /** Redirect the in-progress call to a packaged transfer (blind or warm). */ + transfer?: Omit; /** Tag metadata. */ tag?: Record; /** Mid-conversation agent update. */ diff --git a/typescript/src/types/verbs.ts b/typescript/src/types/verbs.ts index f25855d..a9f6b83 100644 --- a/typescript/src/types/verbs.ts +++ b/typescript/src/types/verbs.ts @@ -199,6 +199,8 @@ export interface LlmBaseOptions { toolHook?: ActionHook; /** Event types to receive via eventHook. */ events?: string[]; + /** Declarative transfer-to-human: injects a transfer tool and runs the packaged transfer when the model calls it. */ + handoff?: Handoff; } export interface LlmVerb extends LlmBaseOptions { @@ -312,6 +314,8 @@ export interface AgentVerb { eventHook?: ActionHook; /** Webhook when the LLM requests a tool/function call. */ toolHook?: ActionHook; + /** Declarative transfer-to-human: injects a transfer tool and runs the packaged transfer when the model calls it. */ + handoff?: Handoff; /** Configuration for playing filler audio while tool calls are in progress. Prevents silence during long-running tool executions. */ toolFiller?: false | { /** Filler mode. 'audio' plays a looping audio file. 'backchannel' uses TTS to speak short phrases. */ @@ -652,6 +656,91 @@ export interface SipReferVerb { eventHook?: ActionHook; } +/** Per-outcome fallback actions when a transfer does not complete normally. */ +export interface TransferDisposition { + /** Action when the destination does not answer within timeout. Default: 'return'. */ + onNoAnswer?: 'return' | 'voicemail' | 'hangup'; + /** Action when the destination is busy. Default: 'return'. */ + onBusy?: 'return' | 'voicemail' | 'hangup'; + /** Action when the destination declines (confirm gate failed, AMD machine, or rejection). Default: 'return'. */ + onDecline?: 'return' | 'voicemail' | 'hangup'; + /** Action when a protocol-level error occurs on the destination leg. Default: 'return'. */ + onFailure?: 'return' | 'voicemail' | 'hangup'; + /** SIP URI or HTTP URL for voicemail. Required when any disposition value is 'voicemail'. */ + voicemailUrl?: string; +} + +/** Human-side acceptance gate: the destination must press a digit to accept. */ +export interface TransferConfirm { + /** Text or URL of the prompt played to the destination. */ + prompt: string; + /** Single DTMF digit the destination must press to accept. */ + digit: string; +} + +/** + * Shared configuration for blind and warm transfer choreography. Reused by the + * `transfer` verb and the `handoff` block on conversational verbs. + */ +export interface TransferOptions { + /** 'blind' hands off immediately; 'warm' lets the agent brief the destination first. */ + mode: 'blind' | 'warm'; + /** blind only: 'refer' sends a SIP REFER; 'dial' places a bridged outbound call. Default: 'refer'. */ + blindMethod?: 'refer' | 'dial'; + /** warm only: true = caller joins a three-way conference and hears the brief; false = caller is parked. Default: false. */ + callerPresent?: boolean; + /** One or more destinations. All are rung simultaneously; first to answer wins. */ + target: Target[]; + /** Caller ID presented to the destination. */ + callerId?: string; + /** warm/parked only: verbs for the parked caller while the agent briefs the destination. */ + onHoldHook?: ActionHook; + /** Seconds to wait for the destination to answer before applying onNoAnswer. Default: 30. */ + timeout?: number; + /** Optional human-side acceptance gate. */ + confirm?: TransferConfirm; + /** warm only: optional answering-machine detection on the destination leg. */ + amd?: Amd; + /** Per-outcome fallback actions. */ + disposition?: TransferDisposition; + /** Fires when the transfer resolves (bridged, returned, voicemail, or failed). */ + actionHook?: ActionHook; + /** Optional hook for in-progress transfer events (ringing, brief started). */ + eventHook?: ActionHook; +} + +/** + * Declarative transfer-to-human config for conversational verbs (`agent`, `llm`/`s2s`). + * The runtime injects a `transfer_to_human` tool and runs the packaged transfer + * choreography when the model calls it. + */ +export interface Handoff extends TransferOptions { + /** 'auto' = LLM writes the summary; 'none' = no spoken brief; {template} = guidance for the summary. Default: 'auto'. */ + brief?: 'auto' | 'none' | { template: string }; + /** Optional voice/vendor for the spoken brief. Defaults to the session synthesizer. */ + briefSynthesizer?: Synthesizer; + /** Override the injected tool name. Default: 'transfer_to_human'. */ + toolName?: string; + /** Override the injected tool description shown to the LLM. */ + toolDescription?: string; +} + +/** + * Packaged blind and warm (parked / three-way) transfer with built-in failure + * handling. Composes {@link TransferOptions} for the shared choreography. + */ +export interface TransferVerb extends TransferOptions { + verb: 'transfer'; + id?: string; + /** warm only: the spoken summary delivered to the destination (human). */ + brief?: { + /** The spoken briefing text. */ + text: string; + /** Optional voice/vendor for the brief. Defaults to the session synthesizer. */ + synthesizer?: Synthesizer; + }; +} + // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- @@ -835,7 +924,8 @@ export type Verb = | TagVerb | SipDeclineVerb | SipRequestVerb - | SipReferVerb; + | SipReferVerb + | TransferVerb; /** A jambonz application — an array of verbs executed sequentially. */ export type JambonzApp = Verb[]; diff --git a/typescript/src/verb-builder.ts b/typescript/src/verb-builder.ts index 6f4a64e..6ab69ae 100644 --- a/typescript/src/verb-builder.ts +++ b/typescript/src/verb-builder.ts @@ -34,6 +34,7 @@ import type { StreamVerb, TagVerb, TranscribeVerb, + TransferVerb, Verb, } from './types/verbs.js'; import { JambonzValidator } from './validator.js'; @@ -207,6 +208,11 @@ export class VerbBuilder { return this.addVerb({ verb: 'sip:refer', ...opts }); } + /** Transfer the caller to a destination (blind, or warm parked/three-way) with built-in failure handling. */ + transfer(opts: Omit): this { + return this.addVerb({ verb: 'transfer', ...opts }); + } + // --- Utility --- /** Set session-level defaults (TTS vendor/voice, STT vendor, VAD, etc.). */ diff --git a/typescript/src/websocket/session.ts b/typescript/src/websocket/session.ts index 8159315..d0b521e 100644 --- a/typescript/src/websocket/session.ts +++ b/typescript/src/websocket/session.ts @@ -158,6 +158,8 @@ export class Session extends EventEmitter { sipRequest(opts: Parameters[0]): this { this.builder.sipRequest(opts); return this; } /** Transfer the call via SIP REFER. */ sipRefer(opts: Parameters[0]): this { this.builder.sipRefer(opts); return this; } + /** Transfer the caller to a destination (blind, or warm parked/three-way) with built-in failure handling. */ + transfer(opts: Parameters[0]): this { this.builder.transfer(opts); return this; } /** Set session-level defaults (TTS vendor/voice, STT vendor, VAD, etc.). */ config(opts: Parameters[0]): this { this.builder.config(opts); return this; } /** Explicitly answer the call (sends a 200 OK). */ From 3d145b13c256dbdb27fe955127ee530387da98d5 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Mon, 22 Jun 2026 11:04:43 -0400 Subject: [PATCH 2/2] chore: update @jambonz/schema to 0.3.16 Co-Authored-By: Claude Opus 4.5 --- typescript/package-lock.json | 28 ++++++------------- typescript/package.json | 2 +- typescript/src/types/llm-vendors.generated.ts | 2 +- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/typescript/package-lock.json b/typescript/package-lock.json index cd68fa6..e9bcae7 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.0", "license": "MIT", "dependencies": { - "@jambonz/schema": "^0.3.15", + "@jambonz/schema": "^0.3.16", "ajv": "^8.17.1", "ws": "^8.18.0" }, @@ -26,20 +26,6 @@ "node": ">=18.0.0" } }, - "../../schema": { - "name": "@jambonz/schema", - "version": "0.3.15", - "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "debug": "^4.3.4" - }, - "devDependencies": { - "eslint": "^9.3.0", - "eslint-plugin-promise": "^6.2.0", - "globals": "^15.3.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -596,8 +582,14 @@ } }, "node_modules/@jambonz/schema": { - "resolved": "../../schema", - "link": true + "version": "0.3.16", + "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.16.tgz", + "integrity": "sha512-7XIEcJzEFmJ1e73uMNT+iE6NFbpaq2beoZ5Kv16bTtt1LYq5TJ6OHIfyCvAqG3WB4cVbJKTXHs0aKibCdLCLOw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "debug": "^4.3.4" + } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", @@ -1489,7 +1481,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2035,7 +2026,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { diff --git a/typescript/package.json b/typescript/package.json index 2ad1dda..61dde22 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -98,7 +98,7 @@ "postpublish": "npm run clean-docs" }, "dependencies": { - "@jambonz/schema": "^0.3.15", + "@jambonz/schema": "^0.3.16", "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 3b311e1..d9c0554 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.15 verbs/agent.schema.json (llm.vendor.enum) +// Source of truth: @jambonz/schema@0.3.16 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