From 606974b3e4fceecc92dd750cfe9d74b52ff33efc Mon Sep 17 00:00:00 2001 From: Anunay Maheshwari Date: Tue, 1 Sep 2026 19:01:21 +0530 Subject: [PATCH 1/2] feat: twilio connector warm transfer --- .../add-twilio-connector-warm-transfer.md | 5 + agents/etc/agents.api.md | 70 ++- agents/package.json | 2 +- agents/src/workflows/index.ts | 4 + agents/src/workflows/warm_transfer.ts | 397 ++++++++++++++++-- .../src/twilio_connector_warm_transfer.ts | 182 ++++++++ examples/src/warm_transfer.ts | 5 +- 7 files changed, 613 insertions(+), 52 deletions(-) create mode 100644 .changeset/add-twilio-connector-warm-transfer.md create mode 100644 examples/src/twilio_connector_warm_transfer.ts diff --git a/.changeset/add-twilio-connector-warm-transfer.md b/.changeset/add-twilio-connector-warm-transfer.md new file mode 100644 index 000000000..38c0f1e78 --- /dev/null +++ b/.changeset/add-twilio-connector-warm-transfer.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': minor +--- + +add TwilioConnectorWarmTransferTask diff --git a/agents/etc/agents.api.md b/agents/etc/agents.api.md index c97be80ae..89f075357 100644 --- a/agents/etc/agents.api.md +++ b/agents/etc/agents.api.md @@ -531,7 +531,7 @@ export class AgentSession extends AgentSession_base force?: boolean; }): Future; // (undocumented) - get interruptionDetection(): "adaptive" | "vad" | undefined; + get interruptionDetection(): "vad" | "adaptive" | undefined; // @internal (undocumented) readonly _keytermDetector: KeytermDetector; get keyterms(): string[]; @@ -561,6 +561,8 @@ export class AgentSession extends AgentSession_base // // @internal (undocumented) _recorderIO?: RecorderIO; + // @internal + _redactionEnabled: boolean; // (undocumented) resumeReplyAuthorization(): void; // @internal (undocumented) @@ -662,6 +664,8 @@ export class AgentSession extends AgentSession_base _waitForIdleHoldReleased(): Promise; // @internal _warnedExpressiveTemplate: boolean; + // @internal + _warnedRealtimeAudioRedaction: boolean; } // Warning: (ae-missing-release-tag) "AgentSessionEventTypes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2728,6 +2732,12 @@ export function createTimedString(opts: { // @public (undocumented) export const createToolOptions: (toolCallId: string, userData?: UserData) => ToolOptions; +// Warning: (ae-missing-release-tag) "createTwilioConnectorWarmTransferTask" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "TwilioConnectorWarmTransferTask" +// +// @public +function createTwilioConnectorWarmTransferTask(options: TwilioConnectorWarmTransferTaskOptions): AgentTask; + // Warning: (ae-missing-release-tag) "createUserInputTranscribedEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -5354,6 +5364,17 @@ export type OpenAIFunctionParameters = { // @public (undocumented) type OpenAIModels = 'openai/gpt-5.5' | 'openai/gpt-5.4' | 'openai/gpt-5.4-mini' | 'openai/gpt-5.4-nano' | 'openai/gpt-5.3-chat-latest' | 'openai/gpt-5.2' | 'openai/gpt-5.2-chat-latest' | 'openai/gpt-5.1' | 'openai/gpt-5.1-chat-latest' | 'openai/gpt-5' | 'openai/gpt-5-mini' | 'openai/gpt-5-nano' | 'openai/gpt-4.1' | 'openai/gpt-4.1-mini' | 'openai/gpt-4.1-nano' | 'openai/gpt-4o' | 'openai/gpt-4o-mini' | 'openai/chat-latest' | 'openai/gpt-oss-120b'; +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "WarmTransferTaskOptions" +// +// @public +type OriginateHumanAgent = (options: { + roomName: string; + identity: string; + room: Room; + jobCtx: JobContext; + signal: AbortSignal; +}) => Promise; + // Warning: (ae-forgotten-export) The symbol "TextOutput" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ParalellTextOutput" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -5958,7 +5979,7 @@ export function resolveExpressiveOptions(expr: ExpressiveOptions, options: { // Warning: (ae-missing-release-tag) "RimeModels" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -type RimeModels = 'rime/arcana' | 'rime/coda' | 'rime/mistv2' | 'rime/mistv3' | 'rime/mist'; +type RimeModels = 'rime/coda' | 'rime/mistv2' | 'rime/mistv3' | 'rime/mist'; // Warning: (ae-missing-release-tag) "RimeOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -6725,6 +6746,8 @@ export class SpeechHandle { exception(): unknown; // @internal (undocumented) get _hasGenerations(): boolean; + // @internal (undocumented) + _holdInterruptions(): void; // (undocumented) get id(): string; // (undocumented) @@ -6748,6 +6771,8 @@ export class SpeechHandle { _numSteps: number; // (undocumented) readonly parent?: SpeechHandle | undefined; + // @internal (undocumented) + _releaseInterruptions(): void; // (undocumented) removeDoneCallback(callback: (sh: SpeechHandle) => void): void; // @internal (undocumented) @@ -8405,6 +8430,28 @@ class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream { // @public type TurnDetectorVersion = 'v1' | 'v1-mini'; +// Warning: (ae-missing-release-tag) "TwilioConnectorWarmTransferTask" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "createTwilioConnectorWarmTransferTask" +// +// @public +class TwilioConnectorWarmTransferTask extends AgentTask { + constructor(options: TwilioConnectorWarmTransferTaskOptions); + // (undocumented) + run(): Promise; +} + +// Warning: (ae-missing-release-tag) "TwilioConnectorWarmTransferTaskOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "createTwilioConnectorWarmTransferTask" +// +// @public +interface TwilioConnectorWarmTransferTaskOptions extends Omit { + phoneNumber: string; + ringingTimeout?: number | null; + twilioAccountSid?: string; + twilioAuthToken?: string; + twilioFromNumber: string; +} + // Warning: (ae-missing-release-tag) "UnexpectedModelBehavior" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -8968,10 +9015,13 @@ interface WarmTransferTaskOptions { dtmf?: string | null; greetingSpeech?: WarmTransferSpeech; holdAudio?: AudioSourceType | AudioConfig | AudioConfig[] | null; + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "WarmTransferResult" + humanAgentIdentity?: string; // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "InstructionParts" instructions?: InstructionParts | string; // (undocumented) llm?: LLM | RealtimeModel | LLMModels | null; + originateHumanAgent?: OriginateHumanAgent; ringingTimeout?: number | null; roomName?: string; sipCallTo?: string; @@ -9099,8 +9149,12 @@ declare namespace workflows { TaskCompletedEvent, TaskGroupOptions, TaskGroupResult, + TwilioConnectorWarmTransferTask, WarmTransferTask, + createTwilioConnectorWarmTransferTask, createWarmTransferTask, + OriginateHumanAgent, + TwilioConnectorWarmTransferTaskOptions, WarmTransferSpeech, WarmTransferResult, WarmTransferTaskOptions, @@ -9161,13 +9215,13 @@ export const zipFunctionCallsAndOutputs: (event: FunctionToolsExecutedEvent) => // src/llm/tool_context.ts:746:3 - (ae-unresolved-link) The @link reference could not be resolved: The reference is ambiguous because "ToolFlag" has more than one declaration; you need to add a TSDoc member reference selector // src/metrics/base.ts:194:3 - (ae-forgotten-export) The symbol "RealtimeModelMetricsInputTokenDetails" needs to be exported by the entry point index.d.ts // src/metrics/base.ts:198:3 - (ae-forgotten-export) The symbol "RealtimeModelMetricsOutputTokenDetails" needs to be exported by the entry point index.d.ts -// src/stt/stt.ts:358:3 - (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "STT" -// src/utils.ts:549:3 - (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "cancelled" +// src/stt/stt.ts:361:3 - (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "STT" +// src/utils.ts:550:3 - (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "cancelled" // src/voice/agent_session.ts:380:3 - (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver -// src/voice/agent_session.ts:988:5 - (ae-forgotten-export) The symbol "RecordingOptions" needs to be exported by the entry point index.d.ts -// src/voice/agent_session.ts:1626:5 - (ae-forgotten-export) The symbol "STTError" needs to be exported by the entry point index.d.ts -// src/voice/agent_session.ts:1626:5 - (ae-forgotten-export) The symbol "TTSError" needs to be exported by the entry point index.d.ts -// src/voice/agent_session.ts:1626:5 - (ae-forgotten-export) The symbol "LLMError" needs to be exported by the entry point index.d.ts +// src/voice/agent_session.ts:994:5 - (ae-forgotten-export) The symbol "RecordingOptions" needs to be exported by the entry point index.d.ts +// src/voice/agent_session.ts:1643:5 - (ae-forgotten-export) The symbol "STTError" needs to be exported by the entry point index.d.ts +// src/voice/agent_session.ts:1643:5 - (ae-forgotten-export) The symbol "TTSError" needs to be exported by the entry point index.d.ts +// src/voice/agent_session.ts:1643:5 - (ae-forgotten-export) The symbol "LLMError" needs to be exported by the entry point index.d.ts // src/voice/amd.ts:314:3 - (ae-unresolved-link) The @link reference could not be resolved: The reference is ambiguous because "waitForTrackPublication" has more than one declaration; you need to add a TSDoc member reference selector // src/voice/amd.ts:314:3 - (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "gateListening" // src/voice/amd.ts:322:3 - (ae-unresolved-link) The @link reference could not be resolved: The package "@livekit/agents" does not have an export "aclose" diff --git a/agents/package.json b/agents/package.json index 4fd662cbf..5943876de 100644 --- a/agents/package.json +++ b/agents/package.json @@ -77,7 +77,7 @@ "heap-js": "^2.6.0", "json-schema": "^0.4.0", "jsonrepair": "^3.14.0", - "livekit-server-sdk": "^2.14.1", + "livekit-server-sdk": "^2.15.0", "ofetch": "^1.5.1", "openai": "^6.8.1", "pidusage": "^4.0.1", diff --git a/agents/src/workflows/index.ts b/agents/src/workflows/index.ts index ee63726a9..d69a4cbcd 100644 --- a/agents/src/workflows/index.ts +++ b/agents/src/workflows/index.ts @@ -8,8 +8,12 @@ export { type TaskGroupResult, } from './task_group.js'; export { + TwilioConnectorWarmTransferTask, WarmTransferTask, + createTwilioConnectorWarmTransferTask, createWarmTransferTask, + type OriginateHumanAgent, + type TwilioConnectorWarmTransferTaskOptions, type WarmTransferSpeech, type WarmTransferResult, type WarmTransferTaskOptions, diff --git a/agents/src/workflows/warm_transfer.ts b/agents/src/workflows/warm_transfer.ts index 50474bf18..f055c3228 100644 --- a/agents/src/workflows/warm_transfer.ts +++ b/agents/src/workflows/warm_transfer.ts @@ -2,8 +2,23 @@ // // SPDX-License-Identifier: Apache-2.0 import type { SIPOutboundConfig } from '@livekit/protocol'; -import { DisconnectReason, type ParticipantKind, Room, RoomEvent } from '@livekit/rtc-node'; -import { AccessToken, RoomServiceClient, SipClient, type VideoGrant } from 'livekit-server-sdk'; +import { + DisconnectReason, + type ParticipantKind, + type RemoteParticipant, + type RemoteTrackPublication, + Room, + RoomEvent, + TrackKind, +} from '@livekit/rtc-node'; +import { + AccessToken, + ConnectTwilioCallRequest_TwilioCallDirection, + ConnectorClient, + RoomServiceClient, + SipClient, + type VideoGrant, +} from 'livekit-server-sdk'; import { z } from 'zod'; import type { LLMModels, STTModelString, TTSModelString } from '../inference/index.js'; import { type JobContext, getJobContext } from '../job.js'; @@ -44,6 +59,25 @@ export interface WarmTransferResult { */ export type WarmTransferSpeech = string | ((session: AgentSession) => SpeechHandle); +/** + * Places the human agent call into the briefing room. See + * {@link WarmTransferTaskOptions.originateHumanAgent}. + * + * @public + */ +export type OriginateHumanAgent = (options: { + /** Name of the room the human agent is dialed into. */ + roomName: string; + /** Participant identity the human agent joins with. */ + identity: string; + /** Connected client room, for observing the human agent join. */ + room: Room; + /** Job context of the caller session, for reaching LiveKit service APIs. */ + jobCtx: JobContext; + /** Aborts when the transfer is cancelled while the dial is pending. */ + signal: AbortSignal; +}) => Promise; + export interface WarmTransferTaskOptions { /** * Signal for application cancellation, such as a consult deadline or application shutdown. @@ -96,6 +130,19 @@ export interface WarmTransferTaskOptions { * attached to it. Must differ from the caller room's name. */ roomName?: string; + /** + * Participant identity the dialed human agent joins with. Defaults to + * `'human-agent-sip'`; {@link WarmTransferResult} reports the value used. + */ + humanAgentIdentity?: string; + /** + * Hook that places the human agent call into the briefing room, replacing + * the default SIP dial (the SIP options above are then unused). It must + * resolve once the call is answered and reject when the dial fails. Its + * `signal` aborts when the transfer is cancelled while the dial is pending; + * the hook should then stop the call it placed. + */ + originateHumanAgent?: OriginateHumanAgent; /** Audio played to the caller while they are on hold during the transfer. */ holdAudio?: AudioSourceType | AudioConfig | AudioConfig[] | null; /** @@ -146,8 +193,9 @@ type IoState = { }; /** - * Build a warm-transfer {@link AgentTask} that dials a human agent over SIP, briefs them - * in a private room, and (on confirmation) merges them into the caller room. + * Build a warm-transfer {@link AgentTask} that dials a human agent over SIP (or through + * the `originateHumanAgent` hook), briefs them in a private room, and (on confirmation) + * merges them into the caller room. * * If the caller hangs up before the merge — including while the human agent's phone is * still ringing — the transfer is cancelled: the pending dial is aborted, the human agent @@ -160,12 +208,14 @@ type IoState = { export function createWarmTransferTask({ abortSignal, sipCallTo, - sipTrunkId: rawSipTrunkId, + sipTrunkId, sipConnection, sipNumber = process.env.LIVEKIT_SIP_NUMBER ?? '', sipHeaders = {}, dtmf, ringingTimeout, + humanAgentIdentity = 'human-agent-sip', + originateHumanAgent, roomName: rawRoomName, holdAudio = { source: BuiltinAudioClip.HOLD_MUSIC, volume: 0.8 }, greetingSpeech, @@ -181,30 +231,24 @@ export function createWarmTransferTask({ tts, allowInterruptions, }: WarmTransferTaskOptions = {}): AgentTask { - if (!sipCallTo) { - throw new Error('`sipCallTo` must be set'); - } - if (rawRoomName !== undefined && rawRoomName.length === 0) { throw new Error('`roomName` must not be empty'); } - // Resolve the SIP trunk: an explicit id wins, then a custom connection (which - // skips the env fallback so it isn't silently overridden), then the env var. - const sipTrunkId = - rawSipTrunkId !== undefined - ? rawSipTrunkId - : sipConnection - ? null - : process.env.LIVEKIT_SIP_OUTBOUND_TRUNK ?? null; - - if (sipTrunkId === null && !sipConnection) { - throw new Error( - '`LIVEKIT_SIP_OUTBOUND_TRUNK` environment variable, `sipTrunkId`, or `sipConnection` must be set', - ); - } + // Resolve the origination up front so a SIP misconfiguration throws at task + // creation, not mid-transfer. + const originate = + originateHumanAgent ?? + createSipOriginateHumanAgent({ + sipCallTo, + sipTrunkId, + sipConnection, + sipNumber, + sipHeaders, + dtmf, + ringingTimeout, + }); - const humanAgentIdentity = 'human-agent-sip'; const backgroundAudio = new BackgroundAudioPlayer(); const logger = log(); @@ -451,8 +495,9 @@ export function createWarmTransferTask({ /** * Dials the human agent into a fresh room and starts a copy of this * task there. Every awaited step is raced against `signal`; on abort the - * `finally` block tears the half-built room/session down (the room/SIP SDK - * calls themselves aren't AbortSignal-aware). + * `finally` block tears the half-built room/session down (the room/session + * SDK calls aren't AbortSignal-aware; the origination hook also receives the + * signal so it can stop a call it already placed). */ const dialHumanAgent = async (signal: AbortSignal): Promise => { if (!callerRoom?.name) { @@ -533,23 +578,14 @@ export function createWarmTransferTask({ throw new Error('dial cancelled'); } - const sip = new SipClient(ctx.info.url); const dialed = await waitUntilAborted( - sip.createSipParticipant( - sipTrunkId ?? '', - sipCallTo, - humanAgentRoomName, - { - participantIdentity: humanAgentIdentity, - waitUntilAnswered: true, - fromNumber: sipNumber || undefined, - headers: sipHeaders, - dtmf: dtmf ?? undefined, - // SIP API takes whole seconds (BigInt coercion throws on fractional input). - ringingTimeout: ringingTimeout != null ? Math.round(ringingTimeout / 1000) : undefined, - }, - sipConnection, - ), + originate({ + roomName: humanAgentRoomName, + identity: humanAgentIdentity, + room, + jobCtx: ctx, + signal, + }), signal, ); if (dialed.isAborted) { @@ -741,6 +777,283 @@ export class WarmTransferTask extends AgentTask { } } +/** + * Build the default origination: validates the SIP options eagerly and dials + * the human agent with `createSipParticipant`, resolving once they answer. + */ +function createSipOriginateHumanAgent(options: { + sipCallTo?: string; + sipTrunkId?: string | null; + sipConnection?: SIPOutboundConfig; + sipNumber: string; + sipHeaders: Record; + dtmf?: string | null; + ringingTimeout?: number | null; +}): OriginateHumanAgent { + const { sipCallTo, sipConnection, sipNumber, sipHeaders, dtmf, ringingTimeout } = options; + if (!sipCallTo) { + throw new Error('`sipCallTo` must be set'); + } + + // Resolve the SIP trunk: an explicit id wins, then a custom connection (which + // skips the env fallback so it isn't silently overridden), then the env var. + const sipTrunkId = + options.sipTrunkId !== undefined + ? options.sipTrunkId + : sipConnection + ? null + : process.env.LIVEKIT_SIP_OUTBOUND_TRUNK ?? null; + + if (sipTrunkId === null && !sipConnection) { + throw new Error( + '`LIVEKIT_SIP_OUTBOUND_TRUNK` environment variable, `sipTrunkId`, or `sipConnection` must be set', + ); + } + + return async ({ roomName, identity, jobCtx }) => { + const sip = new SipClient(jobCtx.info.url); + await sip.createSipParticipant( + sipTrunkId ?? '', + sipCallTo, + roomName, + { + participantIdentity: identity, + waitUntilAnswered: true, + fromNumber: sipNumber || undefined, + headers: sipHeaders, + dtmf: dtmf ?? undefined, + // SIP API takes whole seconds (BigInt coercion throws on fractional input). + ringingTimeout: ringingTimeout != null ? Math.round(ringingTimeout / 1000) : undefined, + }, + sipConnection, + ); + }; +} + +// Twilio reports no-answer/failure only via async status webhooks (not +// consumed here), so cap the wait to keep an unanswered transfer from hanging. +const TWILIO_RINGING_TIMEOUT_MS = 30_000; + +/** + * Options for {@link createTwilioConnectorWarmTransferTask}: the base task + * options minus the SIP fields, plus the Twilio credentials used to place the + * supervisor call. + */ +export interface TwilioConnectorWarmTransferTaskOptions + extends Omit< + WarmTransferTaskOptions, + | 'sipCallTo' + | 'sipTrunkId' + | 'sipConnection' + | 'sipNumber' + | 'sipHeaders' + | 'dtmf' + | 'ringingTimeout' + | 'humanAgentIdentity' + | 'originateHumanAgent' + > { + /** Phone number of the human agent to dial, in E.164 format. */ + phoneNumber: string; + /** Twilio number the call is placed from (the caller ID the human agent sees). */ + twilioFromNumber: string; + /** Twilio account SID. Falls back to the `TWILIO_ACCOUNT_SID` environment variable. */ + twilioAccountSid?: string; + /** Twilio auth token. Falls back to the `TWILIO_AUTH_TOKEN` environment variable. */ + twilioAuthToken?: string; + /** + * How long to wait, in milliseconds, for the human agent to answer before + * giving up and cancelling the call. Defaults to 30 seconds: Twilio reports + * no-answer only via status webhooks, which this task does not consume, so + * the wait is capped instead. `null` disables the cap. + */ + ringingTimeout?: number | null; +} + +/** + * Build a warm-transfer {@link AgentTask} that dials the human agent through + * the LiveKit Twilio connector instead of a SIP trunk: `connectTwilioCall` + * opens an outbound connector session, the Twilio REST API places the call + * with TwiML that streams it into that session, and the dial resolves once the + * connector publishes the answered human agent's audio. + * + * This is the functional core; {@link TwilioConnectorWarmTransferTask} is a + * thin class wrapper over it. + */ +export function createTwilioConnectorWarmTransferTask( + options: TwilioConnectorWarmTransferTaskOptions, +): AgentTask { + const { + phoneNumber, + twilioFromNumber, + twilioAccountSid = process.env.TWILIO_ACCOUNT_SID ?? '', + twilioAuthToken = process.env.TWILIO_AUTH_TOKEN ?? '', + ringingTimeout = TWILIO_RINGING_TIMEOUT_MS, + ...baseOptions + } = options; + + if (!twilioAccountSid || !twilioAuthToken) { + throw new Error( + 'Twilio credentials are required: pass `twilioAccountSid` and `twilioAuthToken` or set' + + ' the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables', + ); + } + const auth = { accountSid: twilioAccountSid, authToken: twilioAuthToken }; + + return createWarmTransferTask({ + ...baseOptions, + // the human agent joins through a connector, not SIP + humanAgentIdentity: 'human-agent-connector', + originateHumanAgent: async ({ roomName, identity, room, jobCtx, signal }) => { + const info = jobCtx.info; + const connector = new ConnectorClient(info.url, info.apiKey, info.apiSecret); + const { connectUrl } = await connector.connectTwilioCall({ + twilioCallDirection: ConnectTwilioCallRequest_TwilioCallDirection.OUTBOUND, + roomName, + participantIdentity: identity, + }); + + const twiml = ``; + const callSid = await createTwilioCall(auth, { + to: phoneNumber, + from: twilioFromNumber, + twiml, + }); + + try { + await waitForConnectorAnswer({ room, identity, ringingTimeout, signal }); + } catch (error) { + // We gave up waiting; cancel the still-ringing call so it doesn't linger. + await cancelTwilioCall(auth, callSid).catch(() => {}); + throw error; + } + }, + }); +} + +/** + * Class wrapper around {@link createTwilioConnectorWarmTransferTask}, + * matching the `new WarmTransferTask(options).run()` API. + */ +export class TwilioConnectorWarmTransferTask extends AgentTask { + readonly #task: AgentTask; + + constructor(options: TwilioConnectorWarmTransferTaskOptions) { + // The wrapper itself never runs as an agent; run() delegates to the + // composed task. Instructions are resolved inside createWarmTransferTask. + super({ instructions: '' }); + this.#task = createTwilioConnectorWarmTransferTask(options); + } + + override run(): Promise { + return this.#task.run(); + } +} + +/** + * The connector publishes the human agent's audio track only after the call is + * answered, so treat that publication as the answer signal. + */ +async function waitForConnectorAnswer(options: { + room: Room; + identity: string; + ringingTimeout: number | null; + signal: AbortSignal; +}): Promise { + const { room, identity, ringingTimeout, signal } = options; + const answered = new Future(); + + const hasPublishedAudio = (participant: RemoteParticipant): boolean => + participant.identity === identity && + [...participant.trackPublications.values()].some((pub) => pub.kind === TrackKind.KIND_AUDIO); + + const resolveIfAnswered = (participant: RemoteParticipant): void => { + if (hasPublishedAudio(participant) && !answered.done) { + answered.resolve(); + } + }; + const onTrackPublished = (_: RemoteTrackPublication, participant: RemoteParticipant): void => + resolveIfAnswered(participant); + + room.on(RoomEvent.TrackPublished, onTrackPublished); + room.on(RoomEvent.ParticipantConnected, resolveIfAnswered); + try { + // Checked after the listeners are attached, so a connector that joined and + // published before this wait started isn't missed. + const existing = room.remoteParticipants.get(identity); + if (existing) { + resolveIfAnswered(existing); + } + + const waitSignal = + ringingTimeout != null + ? AbortSignal.any([signal, AbortSignal.timeout(ringingTimeout)]) + : signal; + const result = await waitUntilAborted(answered.await, waitSignal); + if (result.isAborted) { + if (signal.aborted) { + throw new Error('dial cancelled'); + } + throw new ToolError('human agent did not answer'); + } + } finally { + room.off(RoomEvent.TrackPublished, onTrackPublished); + room.off(RoomEvent.ParticipantConnected, resolveIfAnswered); + } +} + +const TWILIO_API_BASE = 'https://api.twilio.com/2010-04-01'; + +interface TwilioRestAuth { + accountSid: string; + authToken: string; +} + +const twilioRequest = (auth: TwilioRestAuth, path: string, form: Record) => + fetch(`${TWILIO_API_BASE}/Accounts/${encodeURIComponent(auth.accountSid)}${path}`, { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${auth.accountSid}:${auth.authToken}`).toString('base64')}`, + }, + body: new URLSearchParams(form), + }); + +/** Place the human agent call with the Twilio REST API; returns the call SID. */ +async function createTwilioCall( + auth: TwilioRestAuth, + options: { to: string; from: string; twiml: string }, +): Promise { + const resp = await twilioRequest(auth, '/Calls.json', { + To: options.to, + From: options.from, + Twiml: options.twiml, + }); + const body = await resp.text(); + if (!resp.ok) { + throw new Error(`Twilio call creation failed (${resp.status}): ${redactPhoneNumbers(body)}`); + } + return (JSON.parse(body) as { sid: string }).sid; +} + +/** Cancel a still-ringing Twilio call. */ +async function cancelTwilioCall(auth: TwilioRestAuth, callSid: string): Promise { + await twilioRequest(auth, `/Calls/${encodeURIComponent(callSid)}.json`, { Status: 'canceled' }); +} + +/** XML-escape a value and wrap it in double quotes for use as an attribute. */ +function escapeXmlAttribute(value: string): string { + const escaped = value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + return `"${escaped}"`; +} + +/** Mask phone-number-like digit runs before a provider payload is logged or thrown. */ +function redactPhoneNumbers(text: string): string { + return text.replace(/\+?\d{7,15}/g, (match) => `...${match.slice(-4)}`); +} + const renderInstructionPart = (value: Instructions | string): string => typeof value === 'string' ? value : value.value; diff --git a/examples/src/twilio_connector_warm_transfer.ts b/examples/src/twilio_connector_warm_transfer.ts new file mode 100644 index 000000000..a90cb40ef --- /dev/null +++ b/examples/src/twilio_connector_warm_transfer.ts @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Warm transfer with the supervisor dialed through the Twilio connector. + * + * Same support agent as ../warm_transfer.ts, but the escalation runs + * TwilioConnectorWarmTransferTask: connectTwilioCall opens an outbound + * connector session and the Twilio REST API places the supervisor call with + * TwiML that streams it into the session, so no SIP trunk is needed. + */ +import { + type JobContext, + ServerOptions, + cli, + defineAgent, + inference, + llm, + log, + voice, + workflows, +} from '@livekit/agents'; +import { BackgroundVoiceCancellation } from '@livekit/noise-cancellation-node'; +import { fileURLToPath } from 'node:url'; + +// LiveKit credentials are read from LIVEKIT_URL, LIVEKIT_API_KEY, and +// LIVEKIT_API_SECRET. Twilio REST credentials and caller ID: +const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID; // "ACxxxx..." +const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN; +const TWILIO_FROM_NUMBER = process.env.TWILIO_FROM_NUMBER; // your Twilio number, shown to the supervisor +const SUPERVISOR_PHONE_NUMBER = process.env.LIVEKIT_SUPERVISOR_PHONE_NUMBER; // "+12003004000" + +class SupportAgent extends voice.Agent { + constructor() { + super({ + instructions: INSTRUCTIONS, + tools: [ + llm.tool({ + name: 'transfer_to_human', + description: `Called when the user asks to speak to a human agent. This will put the user on hold while the supervisor is connected. + +Ensure that the user has confirmed that they wanted to be transferred. Do not start transfer until the user has confirmed. +Examples on when the tool should be called: +---- +- User: Can I speak to your supervisor? +- Assistant: Yes of course. +---- +- Assistant: I'm unable to help with that, would you like to speak to a human agent? +- User: Yes please. +----`, + execute: async (_, { ctx }) => { + const logger = log().child({ example: 'twilio-connector-warm-transfer' }); + logger.info('tool called to transfer to human'); + const holdSpeech = ctx.session.say( + 'Please hold while I connect you to a human agent.', + { allowInterruptions: false }, + ); + await holdSpeech.waitForPlayout(); + + try { + if (!SUPERVISOR_PHONE_NUMBER || !TWILIO_FROM_NUMBER) { + throw new Error( + 'LIVEKIT_SUPERVISOR_PHONE_NUMBER and TWILIO_FROM_NUMBER must be set', + ); + } + + const result = await new workflows.TwilioConnectorWarmTransferTask({ + phoneNumber: SUPERVISOR_PHONE_NUMBER, + twilioFromNumber: TWILIO_FROM_NUMBER, + twilioAccountSid: TWILIO_ACCOUNT_SID, + twilioAuthToken: TWILIO_AUTH_TOKEN, + chatCtx: ctx.session.history, + // Give up if the supervisor doesn't pick up within 25s with + // `ringingTimeout: 25000` (default: 30s). + instructions: { extra: SUMMARY_INSTRUCTIONS }, + greetingSpeech: (session) => session.generateReply({ toolChoice: 'none' }), + }).run(); + + logger.info( + { humanAgentIdentity: result.humanAgentIdentity }, + 'transfer to human agent successful', + ); + const goodbyeSpeech = ctx.session.say( + "you are on the line with a human agent. I'll be hanging up now.", + { allowInterruptions: false }, + ); + await goodbyeSpeech.waitForPlayout(); + ctx.session.shutdown(); + } catch (error) { + if (error instanceof llm.ToolError) { + logger.error({ error }, 'failed to transfer to human agent with tool error'); + throw error; + } + + logger.error({ error }, 'failed to transfer to human agent'); + throw new llm.ToolError(`failed to transfer to human agent with error: ${error}`); + } + }, + }), + ], + }); + } + + async onEnter(): Promise { + this.session.generateReply(); + } +} + +// No prewarm hook needed: the local EOT model runs in the shared inference +// process (loaded once per host), and the inference VAD (~2MB, in-process) +// lazy-loads on first stream. +export default defineAgent({ + entry: async (ctx: JobContext) => { + const session = new voice.AgentSession({ + vad: new inference.VAD(), + llm: new inference.LLM({ model: 'openai/gpt-4.1-mini' }), + stt: new inference.STT({ model: 'deepgram/nova-3', language: 'en' }), + tts: new inference.TTS({ + model: 'cartesia/sonic-3', + voice: '9626c31c-bec5-4cca-baa8-f8ba9e84c8bc', + }), + }); + + await session.start({ + agent: new SupportAgent(), + room: ctx.room, + inputOptions: { + noiseCancellation: BackgroundVoiceCancellation(), + }, + }); + }, +}); + +const INSTRUCTIONS = ` +# Personality + +You are friendly and helpful, with a welcoming personality +You're naturally curious, empathetic, and intuitive, always aiming to deeply understand the user's intent by actively listening. + +# Environment + +You are engaged in a live, spoken dialogue over the phone. +There are no other ways of communication with the user (no chat, text, visual, etc) + +# Tone + +Your responses are warm, measured, and supportive, typically 1-2 sentences to maintain a comfortable pace. +You speak with gentle, thoughtful pacing, using pauses (marked by "...") when appropriate to let emotional moments breathe. +You naturally include subtle conversational elements like "Hmm," "I see," and occasional rephrasing to sound authentic. +You actively acknowledge feelings ("That sounds really difficult...") and check in regularly ("How does that resonate with you?"). +You vary your tone to match the user's emotional state, becoming calmer and more deliberate when they express distress. + +# Identity + +You are a customer support agent for LiveKit. + +# Transferring to a human + +In some cases, the user may ask to speak to a human agent. This could happen when you are unable to answer their question. +When such is requested, you would always confirm with the user before initiating the transfer. +`; + +const SUMMARY_INSTRUCTIONS = ` +Introduce the conversation from your perspective as the AI assistant who participated in this call: + +WHO you're talking to (name, role, company if mentioned) +WHY they contacted you (goal, problem, request) +WHY a human agent is requested or needed at this point +Brief summary in 100-200 characters from a first-person perspective +`; + +// IMPORTANT: set `agentName` so this worker uses EXPLICIT dispatch. Without it, +// the worker auto-dispatches an agent into EVERY new room in the project — +// including the human agent room that the transfer task creates — which puts a +// second agent on the line with the human agent and produces overlapping voices. +cli.runApp( + new ServerOptions({ + agent: fileURLToPath(import.meta.url), + agentName: process.env.AGENT_DISPATCH_NAME ?? 'telephony-support-agent', + }), +); diff --git a/examples/src/warm_transfer.ts b/examples/src/warm_transfer.ts index 4d10a7467..97fc4a0f1 100644 --- a/examples/src/warm_transfer.ts +++ b/examples/src/warm_transfer.ts @@ -162,5 +162,8 @@ Brief summary in 100-200 characters from a first-person perspective // including the human agent room that WarmTransferTask creates — which puts a // second agent on the line with the human agent and produces overlapping voices. cli.runApp( - new ServerOptions({ agent: fileURLToPath(import.meta.url), agentName: 'warm-transfer' }), + new ServerOptions({ + agent: fileURLToPath(import.meta.url), + agentName: process.env.AGENT_DISPATCH_NAME ?? 'warm-transfer', + }), ); From 728b506e61ed5a0f5a42db2378888a367e1f19c6 Mon Sep 17 00:00:00 2001 From: Anunay Maheshwari Date: Tue, 1 Sep 2026 19:01:21 +0530 Subject: [PATCH 2/2] feat: add Twilio and WhatsApp connector examples --- examples/package.json | 2 +- examples/src/connectors/README.md | 85 +++++ examples/src/connectors/twilio_connector.ts | 238 ++++++++++++ examples/src/connectors/whatsapp_connector.ts | 354 ++++++++++++++++++ pnpm-lock.yaml | 77 ++-- turbo.json | 11 + 6 files changed, 728 insertions(+), 39 deletions(-) create mode 100644 examples/src/connectors/README.md create mode 100644 examples/src/connectors/twilio_connector.ts create mode 100644 examples/src/connectors/whatsapp_connector.ts diff --git a/examples/package.json b/examples/package.json index cfb462a96..91d43311a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -65,7 +65,7 @@ "@opentelemetry/sdk-trace-base": "^2.8.0", "@opentelemetry/sdk-trace-node": "^2.8.0", "@opentelemetry/semantic-conventions": "^1.28.0", - "livekit-server-sdk": "^2.14.1", + "livekit-server-sdk": "^2.15.0", "ws": "catalog:" }, "peerDependencies": { diff --git a/examples/src/connectors/README.md b/examples/src/connectors/README.md new file mode 100644 index 000000000..6339d492b --- /dev/null +++ b/examples/src/connectors/README.md @@ -0,0 +1,85 @@ + + +# Connector Examples + +These examples connect phone and WhatsApp calls to a LiveKit agent with [LiveKit Connectors](https://docs.livekit.io/telephony/connectors/), with no SIP trunk to provision. Each call joins a LiveKit room as a regular participant, so the agent code is unchanged. + +Both examples reuse the support agent from [`../warm_transfer.ts`](../warm_transfer.ts). No new agent code is needed. The webhook servers dispatch that agent by its registered name (`warm-transfer` by default; set `AGENT_DISPATCH_NAME` to dispatch a different agent). + +## Overview + +1. **Twilio (`twilio_connector.ts`)**: A webhook server for [Twilio Media Streams](https://www.twilio.com/docs/voice/media-streams). When a call comes in, it calls `connectTwilioCall`, dispatches the agent, and returns TwiML that bridges the call into the room. A `dial` command places outbound calls through the Twilio REST API. +2. **WhatsApp (`whatsapp_connector.ts`)**: A webhook server for the [WhatsApp Business Calling API](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling). It accepts inbound calls with `acceptWhatsAppCall`, completes outbound calls with `connectWhatsAppCall`, and cleans up on the terminate event. A `dial` command starts outbound calls. + +## Prerequisites + +- Built workspace packages: `pnpm install && pnpm build` in the repository root. The connector API requires `livekit-server-sdk` 2.15.0 or later. +- A running agent. In one terminal, from `examples/`: `node --env-file=.env src/warm_transfer.ts dev` +- A LiveKit Cloud project with connectors enabled, and `LIVEKIT_URL`, `LIVEKIT_API_KEY`, and `LIVEKIT_API_SECRET` in `examples/.env`. +- A publicly reachable URL for the webhook server. For local development, a tunnel like ngrok works: `ngrok http 8080`. + +Both servers verify webhook signatures by default and refuse to start without the secrets that verification needs: `TWILIO_AUTH_TOKEN` plus `TWILIO_WEBHOOK_URL` for Twilio, and `WHATSAPP_APP_SECRET` for WhatsApp. An unauthenticated webhook lets anyone trigger connector calls, so pass `serve --allow-unverified` only for local testing. Meta separately requires a verify token to register the webhook at all; the server always enforces that handshake. + +All commands below run from the `examples/` directory. + +## Twilio + +| Variable | Purpose | +| -------------------- | --------------------------------------------------------------------------------------------------------- | +| `TWILIO_ACCOUNT_SID` | Account SID, for the `dial` command only | +| `TWILIO_AUTH_TOKEN` | Auth token. Signs inbound webhooks and authenticates the `dial` command | +| `TWILIO_FROM_NUMBER` | Your Twilio number, for the `dial` command only | +| `TWILIO_WEBHOOK_URL` | The exact webhook URL configured in the Twilio console. Twilio signs it, so verification uses it verbatim | + +1. Run the server: `node --env-file=.env src/connectors/twilio_connector.ts serve` (add `--allow-unverified` to skip signature verification while testing) +2. In the [Twilio Console](https://console.twilio.com), set your phone number's "A call comes in" webhook to `https:///twilio/voice` with HTTP POST. +3. Call your Twilio number. The agent answers. + +For an outbound call: `node --env-file=.env src/connectors/twilio_connector.ts dial --to +15551234567` + +## WhatsApp + +| Variable | Purpose | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `WHATSAPP_PHONE_NUMBER_ID` | The business phone number ID from your Meta app | +| `WHATSAPP_API_KEY` | A Meta access token | +| `WHATSAPP_CLOUD_API_VERSION` | Cloud API version, default `26.0`. Use a version [the connector supports](https://docs.livekit.io/telephony/connectors/whatsapp/). | +| `WHATSAPP_VERIFY_TOKEN` | A string you choose. Meta requires it when registering the webhook and echoes it in the verification handshake | +| `WHATSAPP_APP_SECRET` | Your Meta app's App Secret (App Dashboard, Settings, Basic), which Meta uses to sign webhook payloads. Not the verify token | + +1. Enable calling on your WhatsApp Business number and subscribe your app to the `calls` webhook field. See the [Meta setup guide](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks). +2. Run the server: `node --env-file=.env src/connectors/whatsapp_connector.ts serve` (add `--allow-unverified` to skip signature verification while testing) +3. Register `https:///whatsapp/webhook` as the webhook URL with your verify token. +4. Call your business number from WhatsApp. The agent answers. + +For an outbound call, keep the server running and run: `node --env-file=.env src/connectors/whatsapp_connector.ts dial --to 15551234567` + +Outbound WhatsApp calling requires [user permission](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/user-call-permissions) and is not available in every country. + +## Warm transfer + +Twilio connector calls support the warm transfer workflow. The customer leg needs no changes. There are two ways to reach the supervisor: + +- `WarmTransferTask` dials the supervisor over a SIP trunk that LiveKit manages. The reused agent from [`../warm_transfer.ts`](../warm_transfer.ts) already escalates this way when the SIP variables it describes are set. +- `TwilioConnectorWarmTransferTask` dials the supervisor through the Twilio connector with your own Twilio credentials, so no trunk is needed. See [`../twilio_connector_warm_transfer.ts`](../twilio_connector_warm_transfer.ts). + +To try the connector path, run the Twilio connector warm transfer agent instead and point the webhook server at its dispatch name: + +```bash +node --env-file=.env src/twilio_connector_warm_transfer.ts dev # registers as "telephony-support-agent" +AGENT_DISPATCH_NAME=telephony-support-agent node --env-file=.env src/connectors/twilio_connector.ts serve +``` + +Calls then reach a support agent that escalates to a supervisor when asked, with the supervisor dialed through the connector too. + +Warm transfer on WhatsApp connector calls is not supported yet. + +## Connectors and SIP + +Connectors are one of two telephony paths. SIP trunking (see [`../telephony_amd.ts`](../telephony_amd.ts)) stays the recommended path when you are starting fresh: it works with any provider and LiveKit manages routing with dispatch rules. Use the connector when your call logic already lives in Twilio, or for WhatsApp, which has no phone number to trunk. + +For setup instructions and more details, see the [main README](../../../README.md). diff --git a/examples/src/connectors/twilio_connector.ts b/examples/src/connectors/twilio_connector.ts new file mode 100644 index 000000000..3d9302749 --- /dev/null +++ b/examples/src/connectors/twilio_connector.ts @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Connect Twilio phone calls to a LiveKit agent with the Twilio connector. + * + * The connector uses Twilio Media Streams instead of a SIP trunk. Your webhook + * answers Twilio's request with TwiML that points at a LiveKit WebSocket URL, + * and the call joins a LiveKit room as a regular participant. + * + * This example reuses the support agent from ../warm_transfer.ts. Run that + * agent in another terminal, then run this server. See README.md for setup. + * + * Docs: https://docs.livekit.io/telephony/connectors/twilio/ + */ +import { + ConnectTwilioCallRequest_TwilioCallDirection, + ConnectorClient, + RoomAgentDispatch, + TwirpError, +} from 'livekit-server-sdk'; +import { createHmac, timingSafeEqual } from 'node:crypto'; +import http from 'node:http'; +import { parseArgs } from 'node:util'; + +// Must match the dispatch name the agent registered with. +const AGENT_NAME = process.env.AGENT_DISPATCH_NAME ?? 'warm-transfer'; +const PORT = Number(process.env.PORT ?? 8080); + +// Twilio credentials are needed for the `dial` command. When the auth token is +// set, inbound webhook signatures are verified with it too. +const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID ?? ''; +const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN ?? ''; +const TWILIO_FROM_NUMBER = process.env.TWILIO_FROM_NUMBER ?? ''; +// The exact public URL configured in the Twilio console. Twilio signs this +// URL, so signature verification needs it verbatim. +const TWILIO_WEBHOOK_URL = process.env.TWILIO_WEBHOOK_URL ?? ''; + +// The API key and secret are read from LIVEKIT_API_KEY and LIVEKIT_API_SECRET. +if (!process.env.LIVEKIT_URL) throw new Error('LIVEKIT_URL must be set'); +const connector = new ConnectorClient(process.env.LIVEKIT_URL); + +const twiml = (connectUrl: string) => ` + + + + +`; + +const FAILURE_TWIML = ` + + We are unable to connect your call right now. Please try again later. +`; + +/** Keep phone numbers out of logs, matching the other telephony examples. */ +function mask(number: string): string { + return number.length > 4 ? `...${number.slice(-4)}` : '****'; +} + +/** Mask phone-number-like digit runs inside provider payloads before logging. */ +function redactNumbers(text: string): string { + return text.replace(/\+?\d{7,15}/g, (match) => mask(match)); +} + +function timingSafeEqualStrings(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer) + ); +} + +/** + * Check the X-Twilio-Signature header: HMAC-SHA1 over the public URL followed + * by the sorted form parameters, keyed with the auth token. + */ +function twilioSignatureValid(form: URLSearchParams, header: string): boolean { + const payload = + TWILIO_WEBHOOK_URL + + [...form.keys()] + .sort() + .map((key) => key + (form.get(key) ?? '')) + .join(''); + const expected = createHmac('sha1', TWILIO_AUTH_TOKEN).update(payload).digest('base64'); + return timingSafeEqualStrings(expected, header); +} + +/** + * Answer Twilio's inbound call webhook with TwiML that bridges the call + * into a LiveKit room and dispatches the agent. + */ +async function handleVoiceWebhook( + req: http.IncomingMessage, + res: http.ServerResponse, + verify: boolean, +): Promise { + let body = ''; + for await (const chunk of req) body += chunk; + const form = new URLSearchParams(body); + + if (verify && !twilioSignatureValid(form, String(req.headers['x-twilio-signature'] ?? ''))) { + console.warn('Rejected webhook with a bad signature'); + res.writeHead(403).end(); + return; + } + + const callSid = form.get('CallSid') ?? ''; + const caller = form.get('From') ?? ''; + console.log(`Inbound call ${callSid} from ${mask(caller)}`); + + let connectUrl: string; + try { + const result = await connector.connectTwilioCall({ + twilioCallDirection: ConnectTwilioCallRequest_TwilioCallDirection.INBOUND, + roomName: `call-${callSid}`, + participantIdentity: caller, + participantName: caller, + agents: [new RoomAgentDispatch({ agentName: AGENT_NAME })], + }); + connectUrl = result.connectUrl; + } catch (e) { + // Answer with TwiML either way: an HTTP error plays an error tone to the caller. + if (e instanceof TwirpError) { + console.error(`Connector rejected call ${callSid}: ${e.code}: ${redactNumbers(e.message)}`); + } else { + console.error(`Failed to connect call ${callSid}:`, e); + } + res.writeHead(200, { 'Content-Type': 'text/xml' }).end(FAILURE_TWIML); + return; + } + + console.log(`Bridging call ${callSid} into room call-${callSid}`); + res.writeHead(200, { 'Content-Type': 'text/xml' }).end(twiml(connectUrl)); +} + +function serve(verify: boolean): void { + http + .createServer(async (req, res) => { + const path = new URL(req.url ?? '/', 'http://localhost').pathname; + if (req.method !== 'POST' || path !== '/twilio/voice') { + res.writeHead(404).end(); + return; + } + + try { + await handleVoiceWebhook(req, res, verify); + } catch (err) { + console.error('Failed to handle webhook:', err); + if (!res.headersSent) res.writeHead(500).end(); + } + }) + .listen(PORT, () => console.log(`Twilio webhook server listening on port ${PORT}`)); +} + +/** + * Place an outbound call through the connector. + * + * The flow has two steps. First, connectTwilioCall returns a WebSocket URL + * and pre-joins the room. The connector participant stays hidden until the + * callee answers. Second, the Twilio REST API creates the call with the + * HTTPS form of that URL, which returns the TwiML above when Twilio fetches it. + */ +async function dial(toNumber: string): Promise { + if (!(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN && TWILIO_FROM_NUMBER)) { + console.error('Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER to dial'); + process.exit(1); + } + + const roomName = `call-out-${Math.floor(Date.now() / 1000)}`; + let connectUrl: string; + try { + const result = await connector.connectTwilioCall({ + twilioCallDirection: ConnectTwilioCallRequest_TwilioCallDirection.OUTBOUND, + roomName, + participantIdentity: toNumber, + agents: [new RoomAgentDispatch({ agentName: AGENT_NAME })], + }); + connectUrl = result.connectUrl; + } catch (e) { + if (!(e instanceof TwirpError)) throw e; + console.error(`Connector rejected the call: ${e.code}: ${redactNumbers(e.message)}`); + process.exit(1); + } + + // Twilio fetches TwiML over HTTPS from the same single-use URL. + const twimlUrl = connectUrl.replace('wss://', 'https://'); + + const auth = Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString('base64'); + const resp = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Calls.json`, + { + method: 'POST', + headers: { Authorization: `Basic ${auth}` }, + body: new URLSearchParams({ To: toNumber, From: TWILIO_FROM_NUMBER, Url: twimlUrl }), + }, + ); + const body = await resp.text(); + if (!resp.ok) { + console.error(`Twilio call creation failed: ${redactNumbers(body)}`); + process.exit(1); + } + const { sid } = JSON.parse(body) as { sid?: string }; + console.log(`Dialing ${mask(toNumber)}, Twilio call SID ${sid}, room ${roomName}`); +} + +function usage(): never { + console.error('Usage: twilio_connector.ts serve [--allow-unverified]'); + console.error(' twilio_connector.ts dial --to '); + process.exit(1); +} + +const [command, ...rest] = process.argv.slice(2); +if (command === 'serve') { + const { values } = parseArgs({ + args: rest, + options: { 'allow-unverified': { type: 'boolean' } }, + }); + const allowUnverified = values['allow-unverified'] ?? false; + const verify = Boolean(TWILIO_AUTH_TOKEN && TWILIO_WEBHOOK_URL) && !allowUnverified; + if (!verify) { + if (!allowUnverified) { + console.error( + 'Set TWILIO_AUTH_TOKEN and TWILIO_WEBHOOK_URL to verify webhook signatures,' + + ' or pass --allow-unverified for local testing', + ); + process.exit(1); + } + console.warn('Webhook signature verification is disabled'); + } + serve(verify); +} else if (command === 'dial') { + const { values } = parseArgs({ args: rest, options: { to: { type: 'string' } } }); + if (!values.to) usage(); + await dial(values.to); +} else { + usage(); +} diff --git a/examples/src/connectors/whatsapp_connector.ts b/examples/src/connectors/whatsapp_connector.ts new file mode 100644 index 000000000..8f9d45b94 --- /dev/null +++ b/examples/src/connectors/whatsapp_connector.ts @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Connect WhatsApp Business calls to a LiveKit agent with the WhatsApp connector. + * + * Meta delivers call events to a webhook you register with your WhatsApp + * Business app. This server handles those events: it accepts inbound calls, + * completes outbound calls, and cleans up when a call ends. The call joins a + * LiveKit room as a regular participant. + * + * This example reuses the support agent from ../warm_transfer.ts. Run that + * agent in another terminal, then run this server. See README.md for setup. + * + * Docs: https://docs.livekit.io/telephony/connectors/whatsapp/ + * Meta docs: https://developers.facebook.com/documentation/business-messaging/whatsapp/calling + */ +import { + type AcceptWhatsAppCallResponse, + ConnectorClient, + DisconnectWhatsAppCallRequest_DisconnectReason, + RoomAgentDispatch, + SessionDescription, + TwirpError, +} from 'livekit-server-sdk'; +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import http from 'node:http'; +import { parseArgs } from 'node:util'; + +// Must match the dispatch name the agent registered with. +const AGENT_NAME = process.env.AGENT_DISPATCH_NAME ?? 'warm-transfer'; +const PORT = Number(process.env.PORT ?? 8080); + +// From your Meta app: the business phone number ID and an access token. +const WHATSAPP_PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID ?? ''; +const WHATSAPP_API_KEY = process.env.WHATSAPP_API_KEY ?? ''; +// Must be a version the connector supports. See the LiveKit docs for the list. +const WHATSAPP_CLOUD_API_VERSION = process.env.WHATSAPP_CLOUD_API_VERSION ?? '26.0'; +// The token you chose when registering the webhook with Meta. +const WHATSAPP_VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN ?? 'livekit-connector-example'; +// Your Meta app secret, used to verify webhook signatures. +const WHATSAPP_APP_SECRET = process.env.WHATSAPP_APP_SECRET ?? ''; + +// Twirp error code for a request that duplicates an existing resource. +const TWIRP_ALREADY_EXISTS = 'already_exists'; + +// The API key and secret are read from LIVEKIT_API_KEY and LIVEKIT_API_SECRET. +if (!process.env.LIVEKIT_URL) throw new Error('LIVEKIT_URL must be set'); +const connector = new ConnectorClient(process.env.LIVEKIT_URL); + +// The parts of Meta's `calls` webhook payload this example uses. +interface WhatsAppCall { + id: string; + event: string; + direction?: string; + from?: string; + session?: { sdp_type: string; sdp: string }; +} + +interface WebhookValue { + calls?: WhatsAppCall[]; + errors?: unknown[]; + statuses?: unknown[]; + metadata?: { phone_number_id?: string }; +} + +interface WebhookBody { + entry?: { changes?: { field: string; value?: WebhookValue }[] }[]; +} + +/** Meta verifies the webhook once at registration with a GET challenge. */ +function handleVerification(query: URLSearchParams, res: http.ServerResponse): void { + if ( + query.get('hub.mode') === 'subscribe' && + query.get('hub.verify_token') === WHATSAPP_VERIFY_TOKEN + ) { + res.writeHead(200).end(query.get('hub.challenge') ?? ''); + } else { + res.writeHead(403).end(); + } +} + +/** Keep phone numbers out of logs, matching the other telephony examples. */ +function mask(number: string): string { + return number.length > 4 ? `...${number.slice(-4)}` : '****'; +} + +/** Mask phone-number-like digit runs inside provider payloads before logging. */ +function redactNumbers(text: string): string { + return text.replace(/\+?\d{7,15}/g, (match) => mask(match)); +} + +function timingSafeEqualStrings(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer) + ); +} + +/** + * Check the X-Hub-Signature-256 header: HMAC-SHA256 over the raw request + * body, keyed with the Meta app secret. + */ +function signatureValid(raw: Buffer, header: string): boolean { + const expected = 'sha256=' + createHmac('sha256', WHATSAPP_APP_SECRET).update(raw).digest('hex'); + return timingSafeEqualStrings(expected, header); +} + +/** + * Accept an inbound call and dispatch the agent. Returns null when the call + * was a webhook redelivery or the accept failed. + */ +async function acceptCall( + call: WhatsAppCall, + callId: string, + phoneNumberId: string, +): Promise { + try { + return await connector.acceptWhatsAppCall({ + whatsappPhoneNumberId: phoneNumberId, + whatsappApiKey: WHATSAPP_API_KEY, + whatsappCloudApiVersion: WHATSAPP_CLOUD_API_VERSION, + whatsappCallId: callId, + sdp: new SessionDescription({ type: call.session!.sdp_type, sdp: call.session!.sdp }), + roomName: `whatsapp-${callId}`, + // The identity ends up in logs, so keep the phone number out of it. + // The name field is redacted and can carry it. + participantIdentity: `wa-${randomUUID().replace(/-/g, '').slice(0, 8)}`, + participantName: call.from ?? '', + agents: [new RoomAgentDispatch({ agentName: AGENT_NAME })], + waitUntilAnswered: true, + }); + } catch (e) { + if (!(e instanceof TwirpError)) throw e; + if (e.code === TWIRP_ALREADY_EXISTS) { + // Meta redelivers webhooks, so a second accept for the same call is expected. + console.log(`Call ${callId} was already accepted`); + } else { + console.error(`Failed to accept call ${callId}: ${e.code}: ${redactNumbers(e.message)}`); + } + return null; + } +} + +/** Route one entry of the webhook's `calls` array to the connector API. */ +async function handleCallEvent(call: WhatsAppCall, phoneNumberId: string): Promise { + const { id: callId, event, direction } = call; + console.log(`Call event ${event} (${direction}) for ${callId}`); + + if (event === 'connect' && direction === 'USER_INITIATED') { + // An inbound call. Accept it right away: the caller's phone is already + // ringing, and WhatsApp drops the call if media takes too long to start. + // waitUntilAnswered makes this request block until the agent is in the + // room, so a failure to answer surfaces here as an error. + const res = await acceptCall(call, callId, phoneNumberId); + if (res !== null) { + console.log(`Accepted call ${callId} into room ${res.roomName}`); + } + } else if (event === 'connect' && direction === 'BUSINESS_INITIATED') { + // The callee answered an outbound call placed with `dial`. The webhook + // carries their SDP answer; pass it on to complete the connection. + // (livekit-server-sdk does not expose waitUntilAnswered here yet, so a + // media failure surfaces as silence on the call instead of an error.) + try { + await connector.connectWhatsAppCall( + callId, + new SessionDescription({ type: call.session!.sdp_type, sdp: call.session!.sdp }), + ); + console.log(`Connected outbound call ${callId}`); + } catch (e) { + if (!(e instanceof TwirpError)) throw e; + if (e.code === TWIRP_ALREADY_EXISTS) { + // Meta redelivers webhooks, so a second connect for the same call is expected. + console.log(`Call ${callId} was already connected`); + } else { + console.error(`Failed to connect call ${callId}: ${e.code}: ${redactNumbers(e.message)}`); + } + } + } else if (event === 'terminate') { + // Tell LiveKit to clean up the connector session and the room. + // Meta also sends this event when the business ended the call, and the + // session is already gone in that case, so an error here is expected. + try { + await connector.disconnectWhatsAppCall( + callId, + WHATSAPP_API_KEY, + DisconnectWhatsAppCallRequest_DisconnectReason.USER_INITIATED, + ); + console.log(`Disconnected call ${callId}`); + } catch (e) { + if (!(e instanceof TwirpError)) throw e; + console.log(`Call ${callId} was already cleaned up: ${e.code}`); + } + } else { + console.warn(`Unhandled call event ${event} for ${callId}`); + } +} + +/** Await a webhook-triggered task and log any unexpected failure. */ +async function runLogged(promise: Promise, description: string): Promise { + try { + await promise; + } catch (err) { + console.error(`Failed to handle ${description}:`, err); + } +} + +/** Meta requires a fast 200, so call handling runs in the background. */ +function handleWebhook(raw: Buffer, tasks: Set>): void { + let webhook: WebhookBody; + try { + webhook = JSON.parse(raw.toString()) as WebhookBody; + } catch { + // Respond 200 anyway. An error response makes Meta redeliver the same payload. + console.warn( + `Ignoring unparseable webhook body: ${redactNumbers(raw.toString().slice(0, 200))}`, + ); + return; + } + + for (const entry of webhook.entry ?? []) { + for (const change of entry.changes ?? []) { + if (change.field !== 'calls') continue; + const value = change.value ?? {}; + for (const error of value.errors ?? []) { + console.warn(`WhatsApp reported an error: ${redactNumbers(JSON.stringify(error))}`); + } + for (const status of value.statuses ?? []) { + console.log(`Status update: ${redactNumbers(JSON.stringify(status))}`); + } + // Prefer the number ID the event arrived on; multi-number apps get several. + const phoneNumberId = value.metadata?.phone_number_id || WHATSAPP_PHONE_NUMBER_ID; + for (const call of value.calls ?? []) { + const task = runLogged(handleCallEvent(call, phoneNumberId), `call event ${call.id}`); + tasks.add(task); + void task.finally(() => tasks.delete(task)); + } + } + } +} + +function serve(verify: boolean): void { + const tasks = new Set>(); + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + if (req.method === 'GET' && url.pathname === '/whatsapp/webhook') { + handleVerification(url.searchParams, res); + } else if (req.method === 'POST' && url.pathname === '/whatsapp/webhook') { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks); + + if (verify && !signatureValid(raw, String(req.headers['x-hub-signature-256'] ?? ''))) { + console.warn('Rejected webhook with a bad signature'); + res.writeHead(403).end(); + return; + } + + try { + handleWebhook(raw, tasks); + res.writeHead(200).end('ok'); + } catch (err) { + console.error('Failed to handle webhook:', err); + res.writeHead(500).end(); + } + } else { + res.writeHead(404).end(); + } + }); + + // The server stops accepting requests, then in-flight call handling + // finishes before the process exits. The connector client is fetch-based, + // so there is nothing else to close. + const shutdown = async (): Promise => { + server.close(); + await Promise.allSettled(tasks); + process.exit(0); + }; + process.once('SIGINT', () => void shutdown()); + process.once('SIGTERM', () => void shutdown()); + + server.listen(PORT, () => console.log(`WhatsApp webhook server listening on port ${PORT}`)); +} + +/** + * Place an outbound call. Keep the webhook server running: Meta sends the + * SDP answer there, and the server completes the connection. + * + * Outbound calling requires user permission and is not available in every + * country. See the Meta docs linked above. + */ +async function dial(toNumber: string): Promise { + if (!(WHATSAPP_PHONE_NUMBER_ID && WHATSAPP_API_KEY)) { + console.error('Set WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_API_KEY to dial'); + process.exit(1); + } + + let res; + try { + res = await connector.dialWhatsAppCall({ + whatsappPhoneNumberId: WHATSAPP_PHONE_NUMBER_ID, + whatsappToPhoneNumber: toNumber, + whatsappApiKey: WHATSAPP_API_KEY, + whatsappCloudApiVersion: WHATSAPP_CLOUD_API_VERSION, + agents: [new RoomAgentDispatch({ agentName: AGENT_NAME })], + }); + } catch (e) { + if (!(e instanceof TwirpError)) throw e; + // Meta rejections ride along in the message, including the fbtrace_id. + console.error(`Dial failed: ${e.code}: ${redactNumbers(e.message)}`); + process.exit(1); + } + console.log(`Dialing ${mask(toNumber)}: call ${res.whatsappCallId}, room ${res.roomName}`); +} + +function usage(): never { + console.error('Usage: whatsapp_connector.ts serve [--allow-unverified]'); + console.error(' whatsapp_connector.ts dial --to '); + process.exit(1); +} + +const [command, ...rest] = process.argv.slice(2); +if (command === 'serve') { + const { values } = parseArgs({ + args: rest, + options: { 'allow-unverified': { type: 'boolean' } }, + }); + const allowUnverified = values['allow-unverified'] ?? false; + if (!(WHATSAPP_PHONE_NUMBER_ID && WHATSAPP_API_KEY)) { + console.warn( + 'WHATSAPP_PHONE_NUMBER_ID or WHATSAPP_API_KEY is not set; accepting calls will fail', + ); + } + const verify = Boolean(WHATSAPP_APP_SECRET) && !allowUnverified; + if (!verify) { + if (!allowUnverified) { + console.error( + 'Set WHATSAPP_APP_SECRET to verify webhook signatures,' + + ' or pass --allow-unverified for local testing', + ); + process.exit(1); + } + console.warn('Webhook signature verification is disabled'); + } + serve(verify); +} else if (command === 'dial') { + const { values } = parseArgs({ args: rest, options: { to: { type: 'string' } } }); + if (!values.to) usage(); + await dial(values.to); +} else { + usage(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8945e5f8..944ef0b50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@types/ws': specifier: ^8.5.10 version: 8.18.1 + ws: + specifier: ^8.21.0 + version: 8.21.3 overrides: adm-zip@<0.6.0: ^0.6.0 @@ -74,7 +77,7 @@ importers: version: 8.10.0(eslint@8.57.0) eslint-config-standard: specifier: ^17.1.0 - version: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.1.1(eslint@8.57.0))(eslint@8.57.0) + version: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.1.1(eslint@8.57.0))(eslint@8.57.0) eslint-config-turbo: specifier: ^2.9.14 version: 2.10.1(eslint@8.57.0)(turbo@2.9.14) @@ -199,8 +202,8 @@ importers: specifier: ^3.14.0 version: 3.14.1 livekit-server-sdk: - specifier: ^2.14.1 - version: 2.14.1 + specifier: ^2.15.0 + version: 2.18.0 ofetch: specifier: ^1.5.1 version: 1.5.1 @@ -383,8 +386,8 @@ importers: specifier: ^1.28.0 version: 1.43.0 livekit-server-sdk: - specifier: ^2.14.1 - version: 2.14.1 + specifier: ^2.15.0 + version: 2.18.0 ws: specifier: 'catalog:' version: 8.21.3 @@ -629,7 +632,7 @@ importers: version: 3.1.3 openai: specifier: ^6.8.1 - version: 6.8.1(ws@8.21.1)(zod@4.3.6) + version: 6.8.1(ws@8.21.3)(zod@4.3.6) devDependencies: '@livekit/agents': specifier: workspace:* @@ -802,7 +805,7 @@ importers: version: 0.4.0 openai: specifier: ^6.8.1 - version: 6.8.1(ws@8.21.1)(zod@4.3.6) + version: 6.8.1(ws@8.21.3)(zod@4.3.6) devDependencies: '@livekit/agents': specifier: workspace:* @@ -1186,7 +1189,7 @@ importers: dependencies: openai: specifier: ^6.8.1 - version: 6.8.1(ws@8.21.1)(zod@4.3.6) + version: 6.8.1(ws@8.21.3)(zod@4.3.6) zod: specifier: ^3.25.76 || ^4.1.8 version: 4.3.6 @@ -2412,6 +2415,9 @@ packages: cpu: [x64] os: [linux] + '@livekit/protocol@1.48.0': + resolution: {integrity: sha512-fYHYgltH6YavAsokl3qsHLkBdQeKCl4UORVTub5crS3t8JtKFZ0uinHDFQ+XXdNKS6Ub9gcOjV+UHcDiqnWXoQ==} + '@livekit/protocol@1.50.4': resolution: {integrity: sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==} @@ -4391,6 +4397,10 @@ packages: resolution: {integrity: sha512-kdpNXKJXps+5jzN4SmGN1w3TVSSDlS45c99R73oqz69EAlApiRT7AeEd3hAn0j2VOCFQ4tr8tegxnL+NbPA/WQ==} engines: {node: '>=18'} + livekit-server-sdk@2.18.0: + resolution: {integrity: sha512-iUGmkbyi0g4DHKQ0Z4VSVs/V+ddp3T75ccQIe6LX94AYlqgyh+SVSM1lQMjFQuI3Qh6Z9v06T4xxV+vWmp7VeQ==} + engines: {node: '>=18'} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5456,18 +5466,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -5951,7 +5949,7 @@ snapshots: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.6.5 - ws: 8.21.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color @@ -6248,6 +6246,10 @@ snapshots: '@livekit/plugins-krisp-viva-x86_64-unknown-linux-gnu@0.2.0': optional: true + '@livekit/protocol@1.48.0': + dependencies: + '@bufbuild/protobuf': 1.10.1 + '@livekit/protocol@1.50.4': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -6381,7 +6383,7 @@ snapshots: '@mistralai/mistralai@2.2.1': dependencies: - ws: 8.21.1 + ws: 8.21.3 zod: 4.3.6 zod-to-json-schema: 3.25.2(zod@4.3.6) transitivePeerDependencies: @@ -7618,7 +7620,7 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.9.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-react: 7.34.1(eslint@8.57.0) @@ -7633,7 +7635,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.1.1(eslint@8.57.0))(eslint@8.57.0): + eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.1.1(eslint@8.57.0))(eslint@8.57.0): dependencies: eslint: 8.57.0 eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) @@ -7654,12 +7656,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: debug: 4.4.3 enhanced-resolve: 5.16.1 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -7671,14 +7673,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.9.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -7699,7 +7701,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) hasown: 2.0.4 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -8499,6 +8501,12 @@ snapshots: camelcase-keys: 9.1.3 jose: 5.2.4 + livekit-server-sdk@2.18.0: + dependencies: + '@bufbuild/protobuf': 1.10.1 + '@livekit/protocol': 1.48.0 + jose: 5.2.4 + load-tsconfig@0.2.5: {} locate-path@5.0.0: @@ -8568,7 +8576,7 @@ snapshots: bent: 7.3.12 https-proxy-agent: 4.0.0 uuid: 11.1.1 - ws: 8.21.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color @@ -8709,11 +8717,6 @@ snapshots: platform: 1.3.6 protobufjs: 7.6.5 - openai@6.8.1(ws@8.21.1)(zod@4.3.6): - optionalDependencies: - ws: 8.21.1 - zod: 4.3.6 - openai@6.8.1(ws@8.21.3)(zod@3.25.76): optionalDependencies: ws: 8.21.3 @@ -8798,7 +8801,7 @@ snapshots: phonic@0.32.15: dependencies: - ws: 8.21.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -9742,8 +9745,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.1: {} - ws@8.21.3: {} yocto-queue@0.1.0: {} diff --git a/turbo.json b/turbo.json index 0af86aefc..962a2a591 100644 --- a/turbo.json +++ b/turbo.json @@ -101,6 +101,17 @@ "SONIOX_API_KEY", "SIP_PARTICIPANT_IDENTITY", "SIP_PHONE_NUMBER", + "AGENT_DISPATCH_NAME", + "PORT", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "TWILIO_FROM_NUMBER", + "TWILIO_WEBHOOK_URL", + "WHATSAPP_PHONE_NUMBER_ID", + "WHATSAPP_API_KEY", + "WHATSAPP_CLOUD_API_VERSION", + "WHATSAPP_VERIFY_TOKEN", + "WHATSAPP_APP_SECRET", "LK_OPENAI_DEBUG", "LK_GOOGLE_DEBUG", "LK_KEYTERMS_DEBUG",