late-glass-71191
12/29/2025, 10:24 PMtall-airline-16756
12/29/2025, 10:24 PMtall-airline-16756
12/29/2025, 10:24 PMlate-glass-71191
12/29/2025, 10:29 PMtall-airline-16756
12/29/2025, 10:30 PMdefineAgent 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.
// 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 docslate-glass-71191
12/29/2025, 10:43 PMParticipant 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);
}
}
);
},
});tall-airline-16756
12/29/2025, 10:43 PMsession.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.late-glass-71191
12/29/2025, 10:52 PMtall-airline-16756
12/29/2025, 10:52 PMlate-glass-71191
12/29/2025, 10:52 PMtall-airline-16756
12/29/2025, 10:53 PMlate-glass-71191
12/30/2025, 3:09 PMcreate_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 attendtall-airline-16756
12/30/2025, 3:09 PMself.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?late-glass-71191
12/30/2025, 3:18 PMtall-airline-16756
12/30/2025, 3:18 PMimport 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?late-glass-71191
12/30/2025, 3:49 PMtall-airline-16756
12/30/2025, 3:49 PMformattedPhoneNumber), 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.late-glass-71191
12/30/2025, 3:50 PMCreating 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 thentall-airline-16756
12/30/2025, 3:50 PMformatPhoneNumber(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?late-glass-71191
12/30/2025, 3:54 PMtall-airline-16756
12/30/2025, 3:54 PMlate-glass-71191
12/30/2025, 4:02 PMtall-airline-16756
12/30/2025, 4:02 PMlate-glass-71191
12/30/2025, 4:43 PMtall-airline-16756
12/30/2025, 4:44 PMlate-glass-71191
12/30/2025, 8:04 PMtall-airline-16756
12/30/2025, 8:04 PMnumbers 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 Agentslate-glass-71191
12/30/2025, 10:19 PMtall-airline-16756
12/30/2025, 10:19 PMemptyTimeout: 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.late-glass-71191
01/01/2026, 12:58 PMtall-airline-16756
01/01/2026, 12:58 PMset: [] 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).late-glass-71191
01/01/2026, 12:59 PMNo 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;
}
}tall-airline-16756
01/01/2026, 12:59 PMset 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?late-glass-71191
01/01/2026, 1:00 PMtall-airline-16756
01/01/2026, 1:00 PMset 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:
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:
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?late-glass-71191
01/01/2026, 1:17 PMtall-airline-16756
01/01/2026, 1:17 PMconsole.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:
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.late-glass-71191
01/01/2026, 1:25 PMtall-airline-16756
01/01/2026, 1:25 PMfunction 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.late-glass-71191
01/01/2026, 1:40 PMtall-airline-16756
01/01/2026, 1:41 PMnumbers 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):
// 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:
numbers: new ListUpdate({ set: merged })
• Remove a number:
// 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?