write sample code in nodejs to create outbound cal...
# ask-ai
l
write sample code in nodejs to create outbound call agent
t
Thanks for reaching out, Anirban. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I’ll help with a concise Node.js example to create an outbound call agent using LiveKit’s server SDK and the Agents API. Which environment do you want this for? • A LiveKit Agent worker (JobContext) that dials out from a room, or • A standalone Node.js script using the LiveKit Server SDK to create a SIP participant directly? If you confirm, I’ll provide the exact code for that environment (including required imports and a minimal package example). Sources: Agents telephony integration | LiveKit docs | Outbound Calling Script | LiveKit docs | SIP Lifecycle Management Agent | LiveKit docs
l
A LiveKit Agent worker (JobContext) that dials out from a room
t
Thanks — responding to your request for a LiveKit Agent worker (JobContext) that dials out from a room. Below is a minimal, ready-to-run Node.js agent using
defineAgent
that reads a phone number from job metadata and creates a SIP participant to place an outbound call. It uses
RoomServiceClient
from
livekit-server-sdk
. Set the env vars listed before dispatching the agent.
Copy code
// outbound-agent.ts
import dotenv from "dotenv";
import { defineAgent, JobContext } from "@livekit/agents";
import { RoomServiceClient } from "livekit-server-sdk";

dotenv.config();

export default defineAgent({
  entry: async (ctx: JobContext) => {
    // Expect job.metadata to be a JSON string containing { "phone_number": "+15105550123" }
    let metadata: any = {};
    try {
      metadata = ctx.job.metadata ? JSON.parse(ctx.job.metadata) : {};
    } catch (e) {
      console.error("Invalid job.metadata JSON:", e);
      await ctx.aclose();
      return;
    }

    const phoneNumber = metadata.phone_number;
    if (!phoneNumber) {
      console.error("No phone_number found in job.metadata");
      await ctx.aclose();
      return;
    }

    const livekitUrl = process.env.LIVEKIT_URL!;
    const livekitKey = process.env.LIVEKIT_API_KEY!;
    const livekitSecret = process.env.LIVEKIT_API_SECRET!;
    const sipTrunkId = process.env.SIP_TRUNK_ID!; // configured in LiveKit console

    if (!livekitUrl || !livekitKey || !livekitSecret || !sipTrunkId) {
      console.error("Missing one of LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, SIP_TRUNK_ID");
      await ctx.aclose();
      return;
    }

    const roomName = ctx.room.name; // ensure agent was dispatched to a room
    const participantIdentity = `outbound-${phoneNumber.replace(/\D/g, "")}`;

    const roomService = new RoomServiceClient(livekitUrl, livekitKey, livekitSecret);

    try {
      await roomService.createSIPParticipant({
        room: roomName,
        sipTrunkId,
        sipCallTo: phoneNumber,
        participantIdentity,
        participantName: "Outbound Caller",
        waitUntilAnswered: true,
      });

      console.log(`Call initiated to ${phoneNumber} and joined room ${roomName}`);
    } catch (err) {
      console.error("Error creating SIP participant:", err);
    } finally {
      // close underlying http client if available
      try { roomService.httpClient?.close(); } catch {}
      await ctx.aclose();
    }
  },
});
Required environment variables: • LIVEKIT_URL • LIVEKIT_API_KEY • LIVEKIT_API_SECRET • SIP_TRUNK_ID Dispatch notes: • Dispatch the agent to an existing room and include
job.metadata
JSON with a
phone_number
field. • The SIP trunk must be configured for outbound calling. If you want, I can: • Add reconnection/retry logic, • Return call status updates into the job log or room, • Show a dispatch example that triggers this agent with metadata. Which would you like next? Sources: Agents telephony integration | LiveKit docs | Outbound Calling Script | LiveKit docs | SIP Lifecycle Management Agent | LiveKit docs
l
router.post('/initiate', async (req, res) => { try { const { trunkId, fromNumber, toNumber, roomName, displayName, participantMetadata, } = req.body; if (!trunkId || !fromNumber || !toNumber) { return res.status(400).json({ success: false, message: 'Missing required fields: agentId, trunkId, fromNumber, toNumber', }); } const phoneRegex = /^\+\d{10,15}$/; if (!phoneRegex.test(toNumber)) { return res.status(400).json({ success: false, message: 'Invalid phone number format. Use E.164 format (e.g., +12135550100)', }); } const result = await livekitService.initiateCall( trunkId, fromNumber, toNumber, ); res.json({ success: true, message: result.message, data: result, }); return } catch (error: any) { console.error('Failed to initiate call:', error); if (error.message.includes('SIP Error')) { return res.status(400).json({ success: false, message: 'Call failed', error: error.message, }); } res.status(500).json({ success: false, message: 'Failed to initiate call', error: error.message, }); return } }) i have a router that initiates call to the phone number but upon receiving there is no agent voice / response export default defineAgent({ prewarm: async (proc: JobProcess) => { console.log('Prewarming agent...'); // Load Silero VAD model for voice activity detection proc.userData.vad = await silero.VAD.load(); console.log('VAD model loaded successfully'); }, entry: async (ctx: JobContext) => { const agentId = process.env.AGENT_ID || 'unknown'; const agentName = process.env.AGENT_NAME || '{{AGENT_NAME}}'; await ctx.connect(); console.log('Connected to LiveKit room'); // Wait for a participant to join const participant = await ctx.waitForParticipant(); console.log(
Participant joined: ${participant.identity}
); let currentCallId: string | null = null; const callerNumber = participant.identity || 'unknown'; try { const call = await prisma.call.create({ data: { agent_id: agentId, callerNumber, status: 'INITIATED', startTime: new Date(), endTime: new Date(), duration: 0, }, }); currentCallId = call.id; console.log(
Call record created: ${currentCallId}
); } catch (error) { console.error('Failed to create call record:', error); } const transcriptBuffer: Array<{ role: 'user' | 'agent'; content: string; timestamp: string; }> = []; const usageCollector = new metrics.UsageCollector(); const agent = new voice.Agent({ instructions: '{{INSTRUCTIONS}}', tools: { getCurrentTime, }, }); // Preloaded VAD const vad = ctx.proc.userData.vad as silero.VAD; const infomaniakSTT = new InfomaniakSTT({ apiToken: process.env.INFOMANIAK_API_TOKEN!, productId: process.env.INFOMANIAK_PRODUCT_ID!, pollingInterval: 500, maxPollingAttempts: 60, }); // This is required for non streaming STT like Infomaniak or OpenAI Whisper const streamAdapter = new stt.StreamAdapter(infomaniakSTT, vad); const session = new voice.AgentSession({ stt: streamAdapter, llm: new inference.LLM({ model: '{{LLM_MODEL}}', }), tts: new inference.TTS({ model: '{{TTS_MODEL}}', voice: '{{TTS_VOICE}}', }), vad: vad, }); session.on(voice.AgentSessionEventTypes.MetricsCollected, async(ev) => { metrics.logMetrics(ev.metrics); usageCollector.collect(ev.metrics); const metricsData = { type: ev.metrics.type, timestamp: new Date().toISOString(), data: ev.metrics, }; await saveMetricsToDatabase(agentId, metricsData); }); session.on( voice.AgentSessionEventTypes.UserInputTranscribed, (ev) => { if (ev.isFinal) { transcriptBuffer.push({ role: 'user', content: ev.transcript, timestamp: new Date().toISOString() }); } } ); const logUsage = async () => { const summary = usageCollector.getSummary(); console.log(
Usage: ${JSON.stringify(summary)}
); }; ctx.addShutdownCallback(logUsage); await session.start({ agent, room: ctx.room, }); console.log('AgentSession started'); if (currentCallId) { try { await prisma.call.update({ where: { id: currentCallId }, data: { status: 'IN_PROGRESS' }, }); console.log('Call status updated to IN_PROGRESS'); } catch (error) { console.error('Failed to update call status:', error); } } await session.generateReply({ instructions: '{{GREETING_INSTRUCTIONS}}', }); session.on( voice.AgentSessionEventTypes.Close, async () => { if (!currentCallId) { console.warn('No call ID available, skipping save'); return; } try { const messages = (session.chatCtx?.items ?? session.history ?? []) as any[]; const agentReplies = messages .filter(item => item.role === 'assistant' && item.type === 'message') .map(item => ({ role: 'agent', content: Array.isArray(item.content) ? item.content.join(' ') : String(item.content), timestamp: (item.created_at ?? new Date().toISOString()) as string, })); const allTranscripts = [...transcriptBuffer, ...agentReplies].sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); if (allTranscripts.length === 0) { console.log('No transcripts to save'); return; } const endTime = new Date(); const call = await prisma.call.findUnique({ where: { id: currentCallId }, }); const duration = call ? (endTime.getTime() - call.startTime.getTime()) / 1000 : 0; await prisma.call.update({ where: { id: currentCallId }, data: { status: 'COMPLETED', endTime, duration, }, }); await prisma.callSummary.create({ data: { call_id: currentCallId, extractedData: { transcripts: allTranscripts, totalTurns: allTranscripts.length, userTurns: transcriptBuffer.length, agentTurns: agentReplies.length, sessionMetadata: { agentId, agentName, roomName: ctx.room.name, participantIdentity: participant.identity, duration, }, } as any, }, }); console.log('Session data saved successfully'); } catch (error) { console.error('Failed to save session data on Close:', error); } } ); }, });
t
Thanks — I see the problem: calls connect but the agent's voice isn't heard. I'll help troubleshoot the audio path so the agent's TTS/stt session actually sends audio into the SIP participant. A few quick clarifying questions first: 1. When the SIP participant answers, do you see the SIP participant in the room list (participant identity) and any published audio tracks in LiveKit logs? 2. Do your agent logs show the TTS model producing audio bytes (or any TTS success messages) after
session.generateReply({ instructions: ... })
? 3. Are there any errors in the agent logs mentioning codecs, publishing tracks, or failing to attach audio to the room? 4. Which TTS provider/model are you using (Infomaniak, OpenAI, local TTS)? Is it configured to return PCM/WAV or an encoded stream? 5. Does the SIP trunk expect a specific audio codec (PCMU/PCMA/opus)? Do you know the trunk codec configuration? Based on typical causes, try these checks and fixes next: • Confirm TTS output: Log/inspect the raw TTS audio buffer to ensure audio is produced and not empty. • Ensure correct audio format: Convert TTS output into a format LiveKit expects (prefer 48kHz mono PCM or Opus). If TTS returns 16kHz, resample to 48kHz and ensure sample width is 16-bit. • Publish audio into the room: Verify your session or agent code actually publishes the TTS audio as an outgoing audio track to the room (check for a call to the SDK publish method or the AgentSession internals that attach TTS output to room). If you rely on the AgentSession to publish, confirm its TTS integration is wired to room publishing. • Confirm participant binding: Ensure the SIP participant is the one subscribing to the agent’s audio track. Verify room-based routing: agent publishes -> room forwards -> SIP participant receives. • Check codec negotiation: If trunk uses PCMU/PCMA, ensure transcode or appropriate codec mapping is enabled on the LiveKit side or trunk so Opus from the agent is converted. • Check VAD gating: VAD may suppress playback if audio level is low or incorrectly detected as silence. Temporarily disable VAD gating to test audio passthrough. • Test with a recorded file: Publish a known test WAV (48kHz PCM) from the agent into the room to confirm the SIP leg receives audio. That isolates TTS vs. publishing issues. If you can share answers to the questions above and: • A snippet showing how TTS output is handed to the session/agent and how publishing is triggered, and • Any logs around TTS generation, publishing, or codec errors, I’ll give targeted code edits (resample/publish example, or how to force-publish TTS into the room) you can drop into your agent.
l
2025-12-30T034318: [dotenv@17.2.3] injecting env (0) from .env.local -- tip: 👥 sync secrets across teammates & machines: https://dotenvx.com/ops 2025-12-30T034318: [dotenv@17.2.3] injecting env (0) from .env.local -- tip: ⚙️ override existing env vars with { override: true } 2025-12-30T034319: [034318.915] [32mINFO[39m (152273): [36mstarting worker[39m 2025-12-30T034319: version: "0.1.0" 2025-12-30T034319: [034318.953] [32mINFO[39m (152273): [36mServer is listening on port 42357[39m 2025-12-30T034319: [034319.192] [32mINFO[39m (152273): [36mregistered worker[39m 2025-12-30T034319: version: "0.1.0" 2025-12-30T034319: id: "AW_Fo37bzeDQ2ns" 2025-12-30T034319: server_info: { 2025-12-30T034319: "edition": "Cloud", 2025-12-30T034319: "version": "1.9.9", 2025-12-30T034319: "protocol": 16, 2025-12-30T034319: "region": "India West", 2025-12-30T034319: "nodeId": "NC_OMUMBAI1A_EPdvUoGC8cT9", 2025-12-30T034319: "debugInfo": "", 2025-12-30T034319: "agentProtocol": 0 2025-12-30T034319: } my agent logs
t
Thanks — I see your agent started and registered a worker (Cloud, v1.9.9). You’ve provided the startup logs which confirm the agent process and worker registration, but those logs don’t show TTS, publishing, or SIP events yet. To troubleshoot why the agent’s voice isn’t heard, please provide or check the following (quick checklist + targeted next steps): 1) Confirm SIP participant in room • After the SIP call answers, do you see the SIP participant in the room list? Check LiveKit room participant list or add logging when ctx.waitForParticipant() returns (you already log identity — confirm it matches the phone number). 2) TTS generation logs • Add/attach logs showing TTS output bytes or success confirmation right after your TTS call (the place where session.generateReply or the TTS provider returns audio). Example: log the returned buffer length and format (sample rate, channels). 3) Confirm publishing of audio track • Ensure the agent session actually publishes the generated TTS audio as an outgoing audio track to the room. Add a log where the session/agent publishes with the track id/name and confirm LiveKit reports a published track event for the agent. 4) Test with a known WAV file • Temporarily bypass TTS and publish a short 48kHz mono PCM WAV (e.g., a spoken “hello”) from the agent into the room to isolate TTS vs publish/transcoding issues. 5) Check codec & trunk expectations • What codec does your SIP trunk require (PCMU/PCMA/opus)? If the trunk uses PCMU/PCMA and the agent publishes Opus, ensure LiveKit or the trunk is configured to transcode or map codecs. Add logs for any codec-negotiation or RTP errors. 6) VAD gating • Disable VAD temporarily to ensure it’s not suppressing agent playback. VAD can block audio if levels or timing look like silence. 7) Enable verbose LiveKit and agent logs • Increase logging around publish/subscribe and room events so you capture: ◦ participant joined/left ◦ track published/updated/unpublished ◦ any codec or RTP warnings/errors Share those logs (especially around the time the call answers and when session.generateReply runs). If you want, I can provide: • A small code snippet to log TTS buffer details and force-publish a WAV into the room, or • A snippet to disable VAD temporarily. Which would you like next, and can you paste logs for: (A) participant join + track published events, and (B) TTS generation output?
l
inbpound calls work perfectly
t
Thanks — good to know inbound calls work perfectly. That tells us SIP connectivity and the SIP->room audio path are OK, so the problem is likely in how the agent’s TTS/audio is produced or published for outbound calls. Quick checklist and focused next steps to isolate & fix the issue: 1) Confirm agent is publishing after outbound connect • Add a log after the agent publishes any audio track. Look for LiveKit events: participant published track, trackSubscribed. If you don’t see a published track for the agent when the SIP participant is present, publishing is the issue. 2) Verify TTS output exists and format • Log the TTS result right after generation: buffer length, sample rate, channels, and format. Example logs: “TTS produced N bytes — 16-bit PCM, 16000 Hz, mono”. • If the buffer is empty or too short, the TTS step failed. 3) Force-test publishing a known WAV • Temporarily bypass TTS and publish a test 48kHz mono PCM WAV (short spoken “hello”) from the agent into the room. If SIP hears it, publishing/transcoding works and TTS integration is the culprit. 4) Check codec expectations and transcoding • Inbound working implies the trunk is configured correctly, but for outbound confirm the trunk can accept whatever codec the agent publishes (typically Opus). If the trunk only accepts PCMU/PCMA, ensure LiveKit or the trunk transcodes Opus -> PCMU/PCMA. 5) Disable VAD temporarily • VAD can suppress outgoing audio. Disable or bypass VAD to confirm it isn’t gating the TTS playback. 6) Confirm you’re targeting the same room participant • Make sure the SIP participant is actually subscribing to the agent’s published track (agent publishes -> room forwards -> SIP leg subscribed). Log participant identities and subscription events to verify routing. If you want, I can provide any of these immediately: • A small code snippet to log TTS buffer details, • A snippet to publish a local 48kHz WAV into the room from your agent, • Example code to temporarily disable VAD or bypass it for TTS playback, • Logging additions to capture participant/track events. Which snippet would you like first? Also paste any logs showing track published/subscribed events around the outbound call answer time and any TTS-generation logs you have.
l
from future import annotations import asyncio import logging from dotenv import load_dotenv import json import os from typing import Any from livekit import rtc, api from livekit.agents import ( AgentSession, Agent, JobContext, function_tool, RunContext, get_job_context, cli, WorkerOptions, RoomInputOptions, ) from livekit.plugins import ( deepgram, openai, cartesia, silero, noise_cancellation, # noqa: F401 ) from livekit.plugins.turn_detector.english import EnglishModel # load environment variables, this is optional, only used for local development load_dotenv(dotenv_path=".env.local") logger = logging.getLogger("outbound-caller") logger.setLevel(logging.INFO) outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID") class OutboundCaller(Agent): def __init__( self, *, name: str, appointment_time: str, dial_info: dict[str, Any], ): super().__init__( instructions=f""" You are a scheduling assistant for a dental practice. Your interface with user will be voice. You will be on a call with a patient who has an upcoming appointment. Your goal is to confirm the appointment details. As a customer service representative, you will be polite and professional at all times. Allow user to end the conversation. When the user would like to be transferred to a human agent, first confirm with them. upon confirmation, use the transfer_call tool. The customer's name is {name}. His appointment is on {appointment_time}. """ ) # keep reference to the participant for transfers self.participant: rtc.RemoteParticipant | None = None self.dial_info = dial_info def set_participant(self, participant: rtc.RemoteParticipant): self.participant = participant async def hangup(self): """Helper function to hang up the call by deleting the room""" job_ctx = get_job_context() await job_ctx.api.room.delete_room( api.DeleteRoomRequest( room=job_ctx.room.name, ) ) @function_tool() async def transfer_call(self, ctx: RunContext): """Transfer the call to a human agent, called after confirming with the user""" transfer_to = self.dial_info["transfer_to"] if not transfer_to: return "cannot transfer call" logger.info(f"transferring call to {transfer_to}") # let the message play fully before transferring await ctx.session.generate_reply( instructions="let the user know you'll be transferring them" ) job_ctx = get_job_context() try: await job_ctx.api.sip.transfer_sip_participant( api.TransferSIPParticipantRequest( room_name=job_ctx.room.name, participant_identity=self.participant.identity, transfer_to=f"tel:{transfer_to}", ) ) logger.info(f"transferred call to {transfer_to}") except Exception as e: logger.error(f"error transferring call: {e}") await ctx.session.generate_reply( instructions="there was an error transferring the call." ) await self.hangup() @function_tool() async def end_call(self, ctx: RunContext): """Called when the user wants to end the call""" logger.info(f"ending the call for {self.participant.identity}") # let the agent finish speaking current_speech = ctx.session.current_speech if current_speech: await current_speech.wait_for_playout() await self.hangup() @function_tool() async def look_up_availability( self, ctx: RunContext, date: str, ): """Called when the user asks about alternative appointment availability Args: date: The date of the appointment to check availability for """ logger.info( f"looking up availability for {self.participant.identity} on {date}" ) await asyncio.sleep(3) return { "available_times": ["1pm", "2pm", "3pm"], } @function_tool() async def confirm_appointment( self, ctx: RunContext, date: str, time: str, ): """Called when the user confirms their appointment on a specific date. Use this tool only when they are certain about the date and time. Args: date: The date of the appointment time: The time of the appointment """ logger.info( f"confirming appointment for {self.participant.identity} on {date} at {time}" ) return "reservation confirmed" @function_tool() async def detected_answering_machine(self, ctx: RunContext): """Called when the call reaches voicemail. Use this tool AFTER you hear the voicemail greeting""" logger.info(f"detected answering machine for {self.participant.identity}") await self.hangup() async def entrypoint(ctx: JobContext): logger.info(f"connecting to room {ctx.room.name}") await ctx.connect() # when dispatching the agent, we'll pass it the approriate info to dial the user # dial_info is a dict with the following keys: # - phone_number: the phone number to dial # - transfer_to: the phone number to transfer the call to when requested dial_info = json.loads(ctx.job.metadata) participant_identity = phone_number = dial_info["phone_number"] # look up the user's phone number and appointment details agent = OutboundCaller( name="Jayden", appointment_time="next Tuesday at 3pm", dial_info=dial_info, ) # the following uses GPT-4o, Deepgram and Cartesia session = AgentSession( turn_detection=EnglishModel(), vad=silero.VAD.load(), stt=deepgram.STT(), # you can also use OpenAI's TTS with openai.TTS() tts=cartesia.TTS(), llm=openai.LLM(model="gpt-4o"), # you can also use a speech-to-speech model like OpenAI's Realtime API # llm=openai.realtime.RealtimeModel() ) # start the session first before dialing, to ensure that when the user picks up # the agent does not miss anything the user says session_started = asyncio.create_task( session.start( agent=agent, room=ctx.room, room_input_options=RoomInputOptions( # enable Krisp background voice and noise removal noise_cancellation=noise_cancellation.BVCTelephony(), ), ) ) #
create_sip_participant
starts dialing the user try: await ctx.api.sip.create_sip_participant( api.CreateSIPParticipantRequest( room_name=ctx.room.name, sip_trunk_id=outbound_trunk_id, sip_call_to=phone_number, participant_identity=participant_identity, # function blocks until user answers the call, or if the call fails wait_until_answered=True, ) ) # wait for the agent session start and participant join await session_started participant = await ctx.wait_for_participant(identity=participant_identity) logger.info(f"participant joined: {participant.identity}") agent.set_participant(participant) except api.TwirpError as e: logger.error( f"error creating SIP participant: {e.message}, " f"SIP status: {e.metadata.get('sip_status_code')} " f"{e.metadata.get('sip_status')}" ) ctx.shutdown() if name == "__main__": cli.run_app( WorkerOptions( entrypoint_fnc=entrypoint, agent_name="outbound-caller", ) ) explain this code to me that make outound calls and how does the agent know which call to attend
t
Thanks — I’ll explain how this outbound agent works and how it knows which call to attend. High-level flow • The agent is an Agent subclass (OutboundCaller) that contains call-related helpers (transfer, end, hangup) and keeps a reference to the SIP participant in
self.participant
. • The entrypoint is invoked for each dispatched job (JobContext). The job’s metadata contains
dial_info
(including the phone number). The entrypoint: a. Connects to the LiveKit room (
await ctx.connect()
). b. Parses
dial_info
from
ctx.job.metadata
and sets
participant_identity
to the phone number. c. Instantiates the OutboundCaller agent with appointment info and dial info. d. Builds an AgentSession with STT, TTS, VAD, turn detection, and an LLM. e. Starts the AgentSession (async task) before dialing so the agent is ready to publish/playback as soon as the callee answers. f. Calls
ctx.api.sip.create_sip_participant(...)
to dial the phone via the configured SIP trunk.
wait_until_answered=True
blocks until the call is answered (or fails). g. After the SIP call is answered, it waits for the SIP participant to appear in the room with
ctx.wait_for_participant(identity=participant_identity)
. h. When the participant joins, it stores the participant on the agent (agent.set_participant(participant)) and the AgentSession is already running to handle STT/TTS and conversation. How the agent knows which call to attend • Job-level metadata: The job dispatched to this worker includes
ctx.job.metadata
which contains
dial_info
with the target
phone_number
. That phone number is used as the
participant_identity
when creating the SIP participant. • Participant identity matching: The code uses
ctx.wait_for_participant(identity=participant_identity)
so the agent waits specifically for the SIP participant whose identity equals the phone number. That binding ties the running AgentSession and the OutboundCaller instance to that specific call. • Per-job isolation: Each dispatched job runs its own entrypoint, creates its own AgentSession and OutboundCaller instance, and waits for its own participant identity — so multiple simultaneous outbound calls are separated by job. Key components and responsibilities • OutboundCaller (Agent subclass) ◦ Holds call-specific state (
self.participant
,
self.dial_info
) and exposes tools (
transfer_call
,
end_call
,
look_up_availability
,
confirm_appointment
,
detected_answering_machine
) annotated with
@function_tool()
for the agent to invoke during conversation. ◦
hangup()
deletes the room via the JobContext API to end the call. • AgentSession ◦ Manages STT (Deepgram), TTS (Cartesia), VAD (Silero), turn detection, and the LLM. ◦
session.start(agent=agent, room=ctx.room, ...)
connects the session to the LiveKit room so it can publish TTS audio and consume STT. ◦
session.generate_reply(...)
is used to have the agent speak (TTS) into the room. • SIP participant creation ◦
ctx.api.sip.create_sip_participant(...)
instructs LiveKit to dial out on the specified SIP trunk and create a participant in the room when answered.
wait_until_answered=True
blocks until answer. ◦ After answer, the SIP leg appears in the room and is matched by identity. Why session is started before dialing • Starting the AgentSession before dialing ensures the agent is already connected, publishing tracks and ready to receive audio (so it won't miss audio that plays immediately when a callee answers). Failure and error handling • If SIP creation fails, the code catches
api.TwirpError
, logs SIP status/metadata, and calls
ctx.shutdown()
to end the job. • Tools like
transfer_call
and
end_call
use the stored participant identity to operate on that specific SIP participant (transfer or hang up). Practical notes and suggestions • Ensure
ctx.job.metadata
contains the expected
dial_info
(phone number and optional
transfer_to
) when dispatching the job. • Make sure the
participant_identity
you pass to create_sip_participant matches what you wait for in
ctx.wait_for_participant(...)
. • To debug routing/audio issues, log participant join events and published tracks, and confirm the AgentSession publishes TTS audio after call answer. • If you need to support multiple concurrent calls, rely on per-job metadata and per-job AgentSession instances (this code already does that). If you want, I can: • Walk through how to dispatch a job with the correct metadata, • Show a minimal example of the job metadata payload, • Add example logging points to confirm TTS generation and track publishing. Which would you like next?
l
could you build similar outbound caller using nodejs
t
Thanks — I’ll provide a concise Node.js outbound caller agent that mirrors your Python example: it starts an AgentSession (STT, TTS, VAD, LLM), dials with createSIPParticipant, waits for the SIP participant, and ties the session to that call via participant identity. Save as outbound-agent.ts (TypeScript; works in Node with ts-node or compiled). Set env vars: LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, SIP_OUTBOUND_TRUNK_ID.
Copy code
import dotenv from "dotenv";
import { defineAgent, JobContext, Agent, RunContext, function_tool, get_job_context, cli, WorkerOptions } from "@livekit/agents";
import { RoomInputOptions } from "@livekit/agents";
import { api } from "livekit"; // server API types
import { AgentSession } from "@livekit/agents";
import * as rtc from "@livekit/rtc"; // types
import * as plugins from "@livekit/plugins"; // STT/TTS/VAD plugins (Deepgram/Cartesia/Silero etc.)
import { EnglishModel } from "@livekit/plugins/turn_detector/english";

dotenv.config();

const OUTBOUND_TRUNK_ID = process.env.SIP_OUTBOUND_TRUNK_ID!;

class OutboundCaller extends Agent {
  participant?: rtc.RemoteParticipant;
  dialInfo: any;
  constructor(opts: { name: string; appointmentTime: string; dialInfo: any }) {
    super({
      instructions: `You are a scheduling assistant for a dental practice. Use voice to confirm the appointment for ${opts.name} at ${opts.appointmentTime}.`,
    });
    this.dialInfo = opts.dialInfo;
  }

  setParticipant(p: rtc.RemoteParticipant) { this.participant = p; }

  async hangup() {
    const jobCtx = get_job_context();
    await jobCtx.api.room.deleteRoom({ room: jobCtx.room.name });
  }

  @function_tool()
  async transfer_call(ctx: RunContext) {
    const transferTo = this.dialInfo.transfer_to;
    if (!transferTo) return "no transfer target";
    await ctx.session.generate_reply({ instructions: "Notifying user of transfer" });
    const jobCtx = get_job_context();
    await jobCtx.api.sip.transferSIPParticipant({
      roomName: jobCtx.room.name,
      participantIdentity: this.participant!.identity,
      transferTo: `tel:${transferTo}`,
    } as any);
    await this.hangup();
    return `transferred to ${transferTo}`;
  }

  @function_tool()
  async end_call(ctx: RunContext) {
    const cur = ctx.session.current_speech;
    if (cur) await cur.wait_for_playout();
    await this.hangup();
  }
}

export default defineAgent({
  entry: async (ctx: JobContext) => {
    console.log(`connecting to room ${ctx.room.name}`);
    await ctx.connect();

    // Expect job.metadata to be JSON with { "phone_number": "+1...", "transfer_to": "...", ... }
    const dialInfo = ctx.job.metadata ? JSON.parse(String(ctx.job.metadata)) : {};
    const phoneNumber = dialInfo.phone_number;
    const participantIdentity = phoneNumber;

    const agent = new OutboundCaller({ name: "Jayden", appointmentTime: "next Tuesday at 3pm", dialInfo });

    // Build session with desired plugins (replace with your configured providers)
    const session = new AgentSession({
      turn_detection: new EnglishModel(),
      vad: await plugins.silero.VAD.load(),
      stt: new plugins.deepgram.STT({ apiKey: process.env.DEEPGRAM_API_KEY }),
      tts: new plugins.cartesia.TTS({ apiKey: process.env.CARTESIA_API_KEY }),
      llm: new plugins.openai.LLM({ apiKey: process.env.OPENAI_API_KEY, model: "gpt-4o" }),
    });

    // Start session before dialing so it's ready to publish
    const sessionStarted = session.start({
      agent,
      room: ctx.room,
      room_input_options: new RoomInputOptions({
        noise_cancellation: new plugins.noise_cancellation.BVCTelephony(),
      }),
    });

    try {
      // Dial outbound via SIP trunk; waitUntilAnswered blocks until answer or error
      await ctx.api.sip.createSIPParticipant({
        roomName: ctx.room.name,
        sipTrunkId: OUTBOUND_TRUNK_ID,
        sipCallTo: phoneNumber,
        participantIdentity,
        waitUntilAnswered: true,
      } as any);

      // ensure session started
      await sessionStarted;

      // wait specifically for the SIP participant to join
      const participant = await ctx.waitForParticipant({ identity: participantIdentity } as any);
      console.log(`participant joined: ${participant.identity}`);
      agent.setParticipant(participant);

      // Optionally generate a greeting now that participant is present
      await session.generate_reply({ instructions: "Say a friendly greeting and confirm appointment details." });

      // Keep agent alive until session closes (AgentSession emits Close)
      await session.closed; // waits until session ends
    } catch (e: any) {
      console.error("error creating SIP participant or running session:", e?.message ?? e);
      ctx.shutdown();
    }
  },
});
How the agent knows which call to attend • Job metadata contains dial info (phone number). That phone number is used as participantIdentity when creating the SIP participant. • The agent waits for the participant with
ctx.waitForParticipant(identity=participantIdentity)
. That binds this job's AgentSession and OutboundCaller instance to that specific SIP call. • Each dispatched job is isolated: multiple jobs mean separate AgentSession instances and separate participant identities. Notes and testing suggestions • Ensure job dispatch includes metadata JSON: { "phone_number": "+15105550123", "transfer_to": "+1..." }. • Start session before dialing so TTS publishes immediately when the callee answers. • If SIP leg hears nothing: log TTS buffer sizes, confirm TTS produces 48kHz mono PCM or Opus, confirm session publishes an audio track after answer, and disable VAD while testing. • Replace plugin initialization with your provider keys and models. If you want, I can: • Provide a short snippet to dispatch a job with correct metadata, • Add a test helper that publishes a local WAV to validate outbound audio. Which next?
l
const dispatch = await agentDispatchClient.createDispatch( roomName, agentName, { metadata: formattedPhoneNumber, }, ); what dispatch does this code create
t
You're creating a dispatch job that launches an agent instance in a specific room and passes it metadata. Specifically: • roomName — the LiveKit room where the agent will connect. • agentName — which agent implementation (by name) to run. • metadata — the data sent with the job (here
formattedPhoneNumber
), available as
ctx.job.metadata
inside the agent. Effect: the system starts the named agent in the given room with the phone number in its job metadata so the agent can read that number (e.g., as
dial_info.phone_number
) and place the outbound call.
l
import { AgentDispatchClient, RoomServiceClient, SipClient, } from 'livekit-server-sdk'; import {agentName} from './agent.js'; const { LIVEKIT_API_KEY = '', LIVEKIT_API_SECRET = '', LIVEKIT_URL = '', SIP_OUTBOUND_TRUNK_ID = '', } = process.env; /** * Formats a phone number to E.164 format for SIP compatibility * @average-fall-32270 number - Raw phone number input * @returns Formatted phone number in E.164 format * @throws Error if the phone number is invalid */ function formatPhoneNumber(number = ''): string { const digits = number.replace(/\D/g, ''); if (digits.length !== 10) { throw new Error( 'Invalid phone number format. Please enter a 10-digit US phone number.', ); } return `+1${digits}`; } /** * Makes a call to a specified phone number using LiveKit and SIP * @average-fall-32270 phoneNumber - The phone number to dial * @throws Error if the phone number is invalid or if there is an error creating the SIP participant */ async function makeCall(phoneNumber: string) { const formattedPhoneNumber = formatPhoneNumber(phoneNumber); const agentDispatchClient = new AgentDispatchClient( LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, ); const roomServiceClient = new RoomServiceClient( LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, ); const sipClient = new SipClient( LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, ); const roomName = `room_${Math.floor(Math.random() * 10_000)}`; const participantIdentity = `caller_${formattedPhoneNumber}}`; await roomServiceClient.createRoom({name: roomName, emptyTimeout: 60}); // Create agent dispatch console.log(
Creating dispatch for agent ${agentName} in room ${roomName}
); const dispatch = await agentDispatchClient.createDispatch( roomName, agentName, { metadata: formattedPhoneNumber, }, ); console.log(
Created dispatch: ${dispatch.id}
); // Create SIP participant to make the call if (!SIP_OUTBOUND_TRUNK_ID.startsWith('ST_')) { throw new Error('SIP_OUTBOUND_TRUNK_ID is not set or invalid'); } console.log(
Dialing ${formattedPhoneNumber} to room ${roomName}
); try { // Create SIP participant to initiate the call const sipParticipant = await sipClient.createSipParticipant( SIP_OUTBOUND_TRUNK_ID, formattedPhoneNumber, roomName, {participantIdentity}, ); console.log(
Created SIP participant: ${sipParticipant.participantId}
); } catch (e) { console.error(
Error creating SIP participant: ${e instanceof Error ? e.message : e}
, ); } } // Get phone number from command line arguments const args = process.argv.slice(2); const phoneNumber = args[0] ?? ''; console.log(
Dialing ${phoneNumber}
); await makeCall(phoneNumber); explain the given code then
t
Thanks — I’ll explain what this code does and how the pieces interact. High-level purpose • Creates a temporary LiveKit room, dispatches a named agent into that room with the phone number in job metadata, then creates a SIP participant that dials the phone and joins the room when answered. Step-by-step behavior 1. Environment and helpers • Reads LiveKit credentials and SIP trunk id from env vars. •
formatPhoneNumber(number)
cleans input to 10 digits and returns an E.164 US number (
+1XXXXXXXXXX
). Throws if not 10 digits. 1. makeCall(phoneNumber) • Formats the phone number with
formatPhoneNumber
. • Constructs three LiveKit server clients: ◦
AgentDispatchClient
— to create/dispatch agent jobs. ◦
RoomServiceClient
— to manage rooms. ◦
SipClient
— to create SIP participants (make outbound calls). • Generates a random
roomName
and a
participantIdentity
based on the formatted phone number. • Calls
roomServiceClient.createRoom(...)
to create the room (with a short empty timeout). • Calls
agentDispatchClient.createDispatch(roomName, agentName, { metadata: formattedPhoneNumber })
◦ This creates a dispatch job that starts the named agent in the new room. ◦ The agent receives the phone number as
ctx.job.metadata
so it can know which number to dial/serve. • Validates
SIP_OUTBOUND_TRUNK_ID
, then calls
sipClient.createSipParticipant(...)
to dial the formatted phone number on the given trunk and join that SIP leg into the room. The call will ring the phone; when answered the SIP participant appears in the room. Key interactions / how the agent knows which call to attend • The dispatch passes the phone number in job metadata. The agent code should read
ctx.job.metadata
to obtain the number and any other dial info. • The SIP participant is created with
participantIdentity
tied to that phone number. The agent can use
ctx.waitForParticipant(identity=participantIdentity)
to wait for that specific SIP participant to join and then bind its AgentSession to that participant. • Each dispatch/job is isolated, so running makeCall multiple times creates separate rooms and separate dispatched agent instances for each call. Errors and logging • Logs progress for room creation, dispatch creation, and SIP participant creation. • Catches and logs errors when SIP participant creation fails. • Throws if trunk ID is missing/invalid or phone number format is invalid. Practical notes • Ensure
agentName
matches a registered agent implementation on your worker fleet. • The agent must read
ctx.job.metadata
and call
ctx.api.sip.create_sip_participant
or use
ctx.waitForParticipant
to bind to the SIP participant identity. • Confirm SIP trunk codec and TTS publishing behavior — if agent audio isn't heard, verify TTS output, publishing, and codec transcoding. • Consider reusing rooms or cleaning them up if you don't want many transient rooms. If you want, I can: • Show a short sample of the agent-side code that reads
ctx.job.metadata
and waits for
participantIdentity
, or • Provide a dispatch example payload and the expected
ctx.job.metadata
structure. Which would you like?
l
basicallt the agent was already starter operating before dispatching to the room ?
t
Good question — short answer: no, the agent isn't running before dispatch. Creating a dispatch launches (starts) the agent instance in the target room. Clarified sequence: 1. You call createDispatch(roomName, agentName, { metadata }) — this tells the system to start an agent job. 2. The worker runs the agent's entrypoint for that job (the agent process starts and executes your entry code). 3. In your entrypoint the agent typically: ◦ Connects to the room (await ctx.connect()), ◦ Starts its AgentSession (STT/TTS/VAD/LLM) so it's ready to publish/receive audio, ◦ Then you create the SIP participant (dial outbound) or wait for one to join. 1. The SIP call rings and when answered the SIP participant joins the room; the agent is already connected and ready to handle that specific participant (you bind by participant identity with ctx.waitForParticipant). Because the agent is started by the dispatch, starting the AgentSession before dialing avoids missed audio when the callee answers.
l
i have a pm2 manager that starts the agent in lkivekit cloud now how can i push the running agent to newly created room and call participant'
t
Thanks — helpful context. Quick answer: you don’t “push” a running agent process into a new room. Instead, keep your pm2-managed worker process running (it hosts the agent code), and dispatch a new job that starts an agent instance connected to the new room. Each dispatch runs the agent entrypoint and creates an AgentSession tied to that room and participant. Short plan and options: 1) Keep pm2 worker(s) running (what you have) • The pm2 process runs your agent binary/worker and listens for dispatch jobs. • When you call AgentDispatchClient.createDispatch(...) the dispatcher tells your worker to start a new agent job that runs the agent entrypoint connected to the target room. 2) Recommended flow to handle a new outbound call • Create a room (RoomServiceClient.createRoom) or reuse an existing room. • Call AgentDispatchClient.createDispatch(roomName, agentName, { metadata: dialInfo }) to start an agent job for that room. • From your server, create the SIP participant (RoomServiceClient/SipClient.createSipParticipant) to dial the phone into that room. • In the agent entrypoint: ◦ call await ctx.connect(); ◦ start AgentSession (STT/TTS/VAD/LLM) so it can publish immediately; ◦ call await ctx.waitForParticipant(identity=participantIdentity) to bind to that SIP call participant; ◦ proceed with session.generate_reply / handle events. 3) If you need the running process to proactively join arbitrary rooms (alternative pattern) • Instead of trying to move a single running agent instance: ◦ Keep a lightweight controller process in pm2 that can request the worker to start jobs (i.e., the same AgentDispatchClient.createDispatch). ◦ Or implement an RPC/HTTP endpoint inside your worker that accepts a "start agent for room X" request; that endpoint should create a new local job/process or instruct the worker runtime to run the agent entrypoint for that room (this still results in a new agent instance). 4) Minimal code sketch (Node) — server that creates room, dispatches agent, then dials: • create room: roomService.createRoom({ name: roomName }); • dispatch agent: agentDispatch.createDispatch(roomName, agentName, { metadata: JSON.stringify(dialInfo) }); • dial: sipClient.createSipParticipant(trunkId, formattedPhoneNumber, roomName, { participantIdentity }); 5) Key notes / pitfalls • The agent code must read ctx.job.metadata to get dial info and participantIdentity. • Start AgentSession before dialing to avoid missed audio. • Each dispatch/job is isolated — multiple simultaneous calls require multiple dispatches. • Do not try to reuse a single AgentSession across different rooms; create one per job/room. • If you want fast startup, use prewarmed workers or keep the worker runtime ready to accept jobs. If you want, I can: • Provide a concrete Node.js example that your pm2-managed worker can call to dispatch an agent and then dial the number, or • Show how to add an HTTP endpoint in your running pm2 process that triggers createDispatch + createSipParticipant. Which would you prefer?
l
should the agent names be unique then ?
t
Good question — short answer: no, agent names do not need to be unique per dispatch. Clarification: • The agent name identifies the agent implementation/type (which entrypoint/worker code to run), not a single running instance. Example: "outbound-caller" means "run that agent code". • Each call/dispatch creates a separate job/instance of that agent implementation. Multiple dispatches can use the same agent name concurrently; each becomes its own job with its own JobContext, AgentSession, and room binding. When you might use unique names: • If you have different agent behaviors or configurations that must be selected at dispatch time, register them under different agent names (e.g., "outbound-caller-v1" vs "outbound-caller-sales"). • You can also pass variant behavior via job metadata (preferred) while keeping a single agent name. Practical rules • Use stable agent names for an implementation (not per-call). • Bind a job to a room/participant using metadata and participantIdentity — that’s how the running instance knows which call to attend. • No per-dispatch uniqueness is required; the dispatch system spawns isolated instances for each job. If you want, I can recommend a naming pattern and show how to pass metadata to select behavior without creating multiple agent names. Which would you prefer?
l
async createSipOutboundTrunk( name: string, address: string, numbers: string[], opts?: CreateSipOutboundTrunkOptions, ): Promise<SIPOutboundTrunkInfo> { if (opts === undefined) { opts = { transport: SIPTransport.SIP_TRANSPORT_AUTO, }; } const req = new CreateSIPOutboundTrunkRequest({ trunk: new SIPOutboundTrunkInfo({ name: name, address: address, numbers: numbers, metadata: opts.metadata, transport: opts.transport, authUsername: opts.authUsername ?? opts.auth_username, authPassword: opts.authPassword ?? opts.auth_password, headers: opts.headers, headersToAttributes: opts.headersToAttributes, includeHeaders: opts.includeHeaders, destinationCountry: opts.destinationCountry, mediaEncryption: opts.mediaEncryption, }), }).toJson(); const data = await this.rpc.request( svc, 'CreateSIPOutboundTrunk', req, await this.authHeader({}, { admin: true }), ); return SIPOutboundTrunkInfo.fromJson(data, { ignoreUnknownFields: true }); } can i not provide an empty array in the phone numbers params
t
Short answer: Yes — you can pass an empty array for
numbers
when calling
createSipOutboundTrunk
. The API allows it. But practical notes: • The
numbers
field associates phone numbers (caller IDs) with the trunk. Leaving it empty is allowed technically but often causes real-world issues: caller ID may be missing/unverified and some SIP providers require at least one verified number for outbound calls. • Best practice: provide at least one E.164-formatted number (e.g.,
+1234567890
) when creating the trunk, or ensure your SIP provider and LiveKit configuration allow anonymous/overridden caller IDs. If you plan to set caller ID per-call, confirm the provider supports that and use the appropriate field (e.g.,
sip_number
/ per-participant caller ID) when creating the SIP participant. Sources: sip | livekit/livekit_composite | DeepWiki | Preserving Caller ID During SIP Transfers in LiveKit Agents
l
alright after the outbpund call is ended by user does the created room delete on its own ?
t
Short answer: not immediately — rooms are not deleted automatically the moment the user hangs up unless you configured automatic cleanup. Two mechanisms control room deletion: • emptyTimeout on room creation: if you set an empty timeout (e.g., 60 seconds) the room will be automatically deleted after it becomes empty for that duration. Your example code used
emptyTimeout: 60
, so that room will be removed ~60s after all participants leave. • explicit deletion: your agent can remove the room immediately by calling the Room API (e.g.,
roomServiceClient.deleteRoom(...)
or your agent’s
hangup()
that calls
ctx.api.room.delete_room
). Use this when you want immediate cleanup (e.g., after call end or voicemail detection). Recommendation: use both — set a reasonable
emptyTimeout
as a safety net and explicitly delete the room when the agent finishes the call to free resources immediately.
l
updates { numbers: ListUpdate { set: [], add: [ '+18153489346' ], remove: [], clear: false } } Failed to update trunk: TwirpError [Internal Server Error]: no trunk numbers specified at TwirpRpc.request (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/dist/TwirpRPC.js6113) at process.processTicksAndRejections (nodeinternal/process/task queues105:5) at async SipClient.updateSipOutboundTrunk (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/src/SipClient.ts63218) at async LiveKitService.updateTrunkFields (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts3007) at async syncPhoneNumberToTrunk (/home/anirban-singha/Documents/agent-starter-node/src/utils/syncPhoneNumber.ts4013) at async <anonymous> (/home/anirban-singha/Documents/agent-starter-node/src/routers/agent.router.ts907) { status: 500, code: 'unknown', metadata: undefined } Failed to update trunk in LiveKit: TwirpError [Internal Server Error]: no trunk numbers specified at TwirpRpc.request (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/dist/TwirpRPC.js6113) at process.processTicksAndRejections (nodeinternal/process/task queues105:5) at async SipClient.updateSipOutboundTrunk (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/src/SipClient.ts63218) at async LiveKitService.updateTrunkFields (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts3007) at async syncPhoneNumberToTrunk (/home/anirban-singha/Documents/agent-starter-node/src/utils/syncPhoneNumber.ts4013) at async <anonymous> (/home/anirban-singha/Documents/agent-starter-node/src/routers/agent.router.ts907) { status: 500, code: 'unknown', metadata: undefined } why do i get this error
t
Thanks — this error happens because the update request ends up with no trunk numbers at the moment the API validates the request. The LiveKit SIP trunk update rejects requests that result in an empty numbers list. Why it happened • Your ListUpdate shows
set: []
and
add: ['+18153489346']
. Depending on how the SDK builds the final request,
set: []
tells the server to replace the entire numbers list with an empty array, and the server validates before applying the
add
entries — resulting in "no trunk numbers specified". • The server requires that the final numbers list is non-empty (or that you explicitly supply numbers in
set
) when performing the update. How to fix it • Provide the desired final numbers in
set
(preferred), e.g.: ◦ set: ['+18153489346'] (and leave add/remove empty) • Or, if you want to append to existing numbers, fetch the current trunk, merge the number client-side, then call update with
set
to the merged list. • Avoid
set: []
when you intend to keep or add numbers. Example (conceptual) • Wrong: ◦ numbers: { set: [], add: ['+1...'] } • Right (replace list): ◦ numbers: { set: ['+1...'] } • Right (append safely): a. GET trunk -> existing = ['+1a'] b. merged = Array.from(new Set([...existing, '+1b'])) c. numbers: { set: merged } If you want, paste the exact code that builds the update request and I’ll show the minimal change to send
set
with the final numbers (or an example merge flow).
l
export async function syncPhoneNumberToTrunk(orgId: string, phoneNumber: string, action: 'add' | 'remove') { try { const trunk = await prisma.outboundTrunk.findFirst({ where: { org_id: orgId, }, }); if (!trunk) { console.log(
No active outbound trunk found for org ${orgId}, skipping sync
); return null; } console.log(
${action === 'add' ? 'Adding' : 'Removing'} ${phoneNumber} ${action === 'add' ? 'to' : 'from'} trunk ${trunk.trunkId}
); let updatedNumbers: string[]; if (action === 'add') { if (trunk.numbers.includes(phoneNumber)) { console.log(
Phone number ${phoneNumber} already in trunk
); return trunk; } updatedNumbers = [...trunk.numbers, phoneNumber]; } else { updatedNumbers = trunk.numbers.filter((num) => num !== phoneNumber); } const updatedTrunk = await prisma.outboundTrunk.update({ where: { id: trunk.id }, data: { numbers: updatedNumbers }, }); try { if(action === "add"){ console.log('phone number ', phoneNumber) await livekitService.updateTrunkFields(trunk.id, { numbers: new ListUpdate({ add: [phoneNumber] }) as any, }); } else{ await livekitService.updateTrunkFields(trunk.id, { numbers: new ListUpdate({ remove: [phoneNumber] }) as any, }) } console.log(
Trunk updated in LiveKit with ${updatedNumbers.length} numbers
); }catch (error) { console.error('Failed to update trunk in LiveKit:', error); } return updatedTrunk; } catch (error) { console.error(
Failed to sync phone number to trunk:
, error); return null; } } async updateTrunkFields(trunkId: string, updates: Partial<TrunkConfig>) { try { const updateData: any = {}; if (updates.name) updateData.name = updates.name; if (updates.address) updateData.address = updates.address; if (updates.numbers) updateData.numbers = updates.numbers; if (updates.authUsername) updateData.auth_username = updates.authUsername; if (updates.authPassword) updateData.auth_password = updates.authPassword; console.log('updates ', updateData) await this.sipClient.updateSipOutboundTrunk( trunkId, updateData ); return updateData; } catch (error: any) { console.error('Failed to update trunk:', error); throw error; } }
t
Thanks — the error happens because the LiveKit update call is being sent with an empty
set
for
numbers
, which the server rejects (it results in "no trunk numbers specified"). Your Prisma update already writes the correct merged list locally, but the LiveKit update uses a ListUpdate with
add
only, while some RPC paths interpret
set: []
or a missing final list as empty during validation. Fixes (pick one): 1) Send the final numbers list via
set
(recommended) • Read the current trunk from LiveKit (or use your merged
updatedNumbers
) and call update with
numbers: new ListUpdate({ set: updatedNumbers })
. 2) Build the final list before updating LiveKit • Fetch current trunk numbers from LiveKit: ◦ GET trunk -> existing = [...] ◦ merged = action==='add' ? uniq([...existing, phoneNumber]) : existing.filter(...) ◦ call update with
set: merged
. 3) Avoid using
set: []
with
add
together • Do not send
set: []
in the same update that intends to add numbers. The server may process
set
first and reject empty results. Concrete change for your code (minimal): Replace the livekitService call inside syncPhoneNumberToTrunk with: if (action === "add") { await livekitService.updateTrunkFields(trunk.id, { numbers: new ListUpdate({ set: updatedNumbers }) } as any); } else { await livekitService.updateTrunkFields(trunk.id, { numbers: new ListUpdate({ set: updatedNumbers }) } as any); } Or, if you prefer incremental updates, first fetch the trunk from LiveKit, compute merged list, then call
set
. Also ensure updateTrunkFields forwards the ListUpdate object directly (your current code does that) and log the final payload you send (you already log updateData). If you want, I can provide an exact patch for your functions that: • fetches the existing trunk from LiveKit, • merges numbers safely, • calls update with
set: merged
. Which would you like?
l
alright follow first step and provide code
t
Thanks — below is a concise patch that implements step 1: compute the final numbers list and send it via
set
when calling LiveKit. It fetches the current trunk numbers from LiveKit, merges with your local update, and calls update with
set: mergedNumbers
. 1) Update syncPhoneNumberToTrunk to call LiveKit with the final list:
Copy code
import { ListUpdate } from 'livekit-server-sdk'; // adjust import path if needed

export async function syncPhoneNumberToTrunk(orgId: string, phoneNumber: string, action: 'add' | 'remove') {
  try {
    const trunk = await prisma.outboundTrunk.findFirst({
      where: { org_id: orgId },
    });
    if (!trunk) {
      console.log(`No active outbound trunk found for org ${orgId}, skipping sync`);
      return null;
    }

    // compute updatedNumbers locally (same as you have)
    let updatedNumbers: string[];
    if (action === 'add') {
      if (trunk.numbers.includes(phoneNumber)) {
        console.log(`Phone number ${phoneNumber} already in trunk`);
        return trunk;
      }
      updatedNumbers = [...trunk.numbers, phoneNumber];
    } else {
      updatedNumbers = trunk.numbers.filter((num) => num !== phoneNumber);
    }

    // persist locally
    const updatedTrunk = await prisma.outboundTrunk.update({
      where: { id: trunk.id },
      data: { numbers: updatedNumbers },
    });

    // Fetch current trunk from LiveKit to be safe
    const livekitTrunk = await livekitService.getTrunkById(trunk.id); // implement/get wrapper that calls RoomServiceClient or SipClient GET
    const existing = Array.isArray(livekitTrunk?.numbers) ? livekitTrunk.numbers : [];

    // Merge deterministically (avoid duplicates)
    const merged = action === 'add'
      ? Array.from(new Set([...existing, phoneNumber]))
      : existing.filter(n => n !== phoneNumber);

    // Send final list using `set`
    await livekitService.updateTrunkFields(trunk.id, {
      numbers: new ListUpdate({ set: merged }) as any,
    });

    console.log(`Trunk updated in LiveKit with ${merged.length} numbers`);
    return updatedTrunk;
  } catch (error) {
    console.error(`Failed to sync phone number to trunk:`, error);
    return null;
  }
}
2) Ensure updateTrunkFields passes the ListUpdate through unchanged:
Copy code
async updateTrunkFields(trunkId: string, updates: Partial<TrunkConfig>) {
  try {
    const updateData: any = {};
    if (updates.name) updateData.name = updates.name;
    if (updates.address) updateData.address = updates.address;
    if (updates.numbers) updateData.numbers = updates.numbers; // pass ListUpdate obj
    if (updates.authUsername) updateData.auth_username = updates.authUsername;
    if (updates.authPassword) updateData.auth_password = updates.authPassword;

    console.log('updates ', updateData);

    await this.sipClient.updateSipOutboundTrunk(trunkId, updateData);
    return updateData;
  } catch (error: any) {
    console.error('Failed to update trunk:', error);
    throw error;
  }
}
Notes: • Implement
livekitService.getTrunkById(trunk.id)
to call the LiveKit API and return the current trunk object (including
numbers
). • Using
set: merged
guarantees the server sees the final numbers list and avoids the "no trunk numbers specified" error. • Deduplicate with
Set
to avoid duplicate numbers. • If you cannot fetch the trunk from LiveKit, you can use your local
updatedNumbers
instead of
merged
when calling
set
, but fetching is safer against concurrent changes. If you want, I can provide the
getTrunkById
wrapper example for your SDK version. Which do you prefer?
l
Failed to update trunk: TwirpError [Internal Server Error]: no trunk numbers specified at TwirpRpc.request (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/dist/TwirpRPC.js6113) at process.processTicksAndRejections (nodeinternal/process/task queues105:5) at async SipClient.updateSipOutboundTrunk (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/src/SipClient.ts63218) at async LiveKitService.updateTrunkFields (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts3005) at async syncPhoneNumberToTrunk (/home/anirban-singha/Documents/agent-starter-node/src/utils/syncPhoneNumber.ts375) at async <anonymous> (/home/anirban-singha/Documents/agent-starter-node/src/routers/agent.router.ts907) { status: 500, code: 'unknown', metadata: undefined } Failed to sync phone number to trunk: TwirpError [Internal Server Error]: no trunk numbers specified at TwirpRpc.request (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/dist/TwirpRPC.js6113) at process.processTicksAndRejections (nodeinternal/process/task queues105:5) at async SipClient.updateSipOutboundTrunk (/home/anirban-singha/Documents/agent-starter-node/node_modules/.pnpm/livekit-server-sdk@2.14.2/node_modules/livekit-server-sdk/src/SipClient.ts63218) at async LiveKitService.updateTrunkFields (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts3005) at async syncPhoneNumberToTrunk (/home/anirban-singha/Documents/agent-starter-node/src/utils/syncPhoneNumber.ts375) at async <anonymous> (/home/anirban-singha/Documents/agent-starter-node/src/routers/agent.router.ts907) { status: 500, code: 'unknown', metadata: undefined }
t
Thanks — sorry this is still failing. The error means the update request you're sending to LiveKit still results in no trunk numbers. To fix it I need to confirm a couple quick facts and see one small log. Please do two things and paste the outputs here: 1) Log the final payload sent to LiveKit right before the update call. In updateTrunkFields add:
Copy code
console.log('LiveKit update payload:', JSON.stringify(updateData));
Paste that output. 2) Log the merged numbers array you intend to send (the
merged
or
updatedNumbers
value) right before calling updateTrunkFields:
Copy code
console.log('numbers to set on trunk:', merged);
Paste that output. With those two logs I can tell whether you're sending an empty
set
or the ListUpdate is malformed. If you prefer, paste the actual console output you already have around the failing call and I’ll point to the exact line to change.
l
Updating LiveKit trunk: { numbers: ListUpdate { set: [ '+18153489346' ], add: [], remove: [], clear: false } }
t
Thanks — the payload you posted shows the numbers are wrapped with markdown-like text ("+18153489346") instead of a plain E.164 string. LiveKit expects raw phone numbers (e.g. "+18153489346"), and the server rejects the request because it doesn't see valid numbers. Fix: • Strip any Markdown/HTML or display formatting before building the ListUpdate. Send plain strings. Example minimal fix before calling update:
Copy code
function normalizeNumber(n: string) {
  return n.replace(/.*?(\+\d{10,15}).*/, "$1");
}

const merged = ['[+18153489346](tel:+18153489346)'].map(normalizeNumber);
// merged => ['+18153489346']

await livekitService.updateTrunkFields(trunk.id, {
  numbers: new ListUpdate({ set: merged }),
});
Actionable checks: • Log the final array right before update to confirm it contains plain E.164 numbers. • Ensure any formatting step (UI, clipboard, templating) isn't injecting markdown. If you want, paste the code that produces the phone string and I’ll give the exact replacement.
l
async updateSipOutboundTrunk( sipTrunkId: string, trunk: SIPOutboundTrunkInfo, ): Promise<SIPOutboundTrunkInfo> { const req = new UpdateSIPOutboundTrunkRequest({ sipTrunkId, action: { case: 'replace', value: trunk, }, }).toJson(); const data = await this.rpc.request( svc, 'UpdateSIPOutboundTrunk', req, await this.authHeader({}, { admin: true }), ); return SIPOutboundTrunkInfo.fromJson(data, { ignoreUnknownFields: true }); } declare class SIPOutboundTrunkInfo extends Message<SIPOutboundTrunkInfo> { /** * @generated from field: string sip_trunk_id = 1; */ sipTrunkId: string; /** * Human-readable name for the Trunk. * * @generated from field: string name = 2; */ name: string; /** * User-defined metadata for the Trunk. * * @generated from field: string metadata = 3; */ metadata: string; /** * Hostname or IP that SIP INVITE is sent too. * Note that this is not a SIP URI and should not contain the 'sip:' protocol prefix. * * @generated from field: string address = 4; */ address: string; /** * country where the call terminates as ISO 3166-1 alpha-2 (https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). This will be used by the livekit infrastructure to route calls. * * @generated from field: string destination_country = 14; */ destinationCountry: string; /** * SIP Transport used for outbound call. * * @generated from field: livekit.SIPTransport transport = 5; */ transport: SIPTransport; /** * Numbers used to make the calls. Random one from this list will be selected. * * @generated from field: repeated string numbers = 6; */ numbers: string[]; /** * Username and password used to authenticate with SIP server. * May be empty to have no authentication. * * @generated from field: string auth_username = 7; */ authUsername: string; /** * @generated from field: string auth_password = 8; */ authPassword: string; /** * Include these SIP X-* headers in INVITE request. * These headers are sent as-is and may help identify this call as coming from LiveKit for the other SIP endpoint. * * @generated from field: map<string, string> headers = 9; */ headers: { [key: string]: string }; /** * Map SIP X-* headers from 200 OK to SIP participant attributes. * Keys are the names of X-* headers and values are the names of attributes they will be mapped to. * * @generated from field: map<string, string> headers_to_attributes = 10; */ headersToAttributes: { [key: string]: string }; /** * Map LiveKit attributes to SIP X-* headers when sending BYE or REFER requests. * Keys are the names of attributes and values are the names of X-* headers they will be mapped to. * * @generated from field: map<string, string> attributes_to_headers = 11; */ attributesToHeaders: { [key: string]: string }; /** * Map SIP headers from 200 OK to sip.h.* participant attributes automatically. * * When the names of required headers is known, using headers_to_attributes is strongly recommended. * * When mapping 200 OK headers to follow-up request headers with attributes_to_headers map, * lowercase header names should be used, for example: sip.h.x-custom-header. * * @generated from field: livekit.SIPHeaderOptions include_headers = 12; */ includeHeaders: SIPHeaderOptions; /** * @generated from field: livekit.SIPMediaEncryption media_encryption = 13; */ mediaEncryption: SIPMediaEncryption; constructor(data?: PartialMessage<SIPOutboundTrunkInfo>); static readonly runtime: typeof proto3; static readonly typeName = "livekit.SIPOutboundTrunkInfo"; static readonly fields: FieldList; static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): SIPOutboundTrunkInfo; static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): SIPOutboundTrunkInfo; static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): SIPOutboundTrunkInfo; static equals(a: SIPOutboundTrunkInfo | PlainMessage<SIPOutboundTrunkInfo> | undefined, b: SIPOutboundTrunkInfo | PlainMessage<SIPOutboundTrunkInfo> | undefined): boolean; } what to provide in updated trunk for nummbers
t
Provide the trunk
numbers
as a plain array of E.164 phone number strings (no markdown, no extra text). The server expects valid phone numbers like "+18153489346". The field must not be empty when updating — send the final list via
set
(or ensure the merged final list is non-empty). What to send in the update (examples): • Replace entire list (preferred):
Copy code
// using ListUpdate.set with plain E.164 numbers
numbers: new ListUpdate({ set: ["+18153489346", "+14155552671"] })
• Append/merge safely (server-side final list must be non-empty): 1. Fetch current trunk numbers. 2. Compute merged = Array.from(new Set([...existing, ...toAdd])); 3. Send:
Copy code
numbers: new ListUpdate({ set: merged })
• Remove a number:
Copy code
// compute remaining = existing.filter(n => n !== "+18153489346")
numbers: new ListUpdate({ set: remaining })
Checks to avoid the "no trunk numbers specified" error: • Ensure strings are plain E.164 (e.g., "+18153489346"), not formatted like "+1815...". • Do not send
set: []
unless you intentionally want an empty trunk and your provider allows that. • Log the final payload just before calling update to confirm
numbers.set
contains the plain strings. • If you must use incremental `add`/`remove`, still fetch existing numbers first and send a
set
of the final merged list to guarantee the server sees a non-empty list. If you want, I can provide a one-file code patch that: • fetches the trunk from LiveKit, • normalizes incoming numbers to E.164, • merges them, • and calls update with
ListUpdate({ set: merged })
. Which would you prefer?