late-glass-71191
03/05/2026, 6:38 PMtall-airline-16756
03/05/2026, 6:38 PMtall-airline-16756
03/05/2026, 6:39 PMprovider/model:language for stt, tts, and llm when creating the AgentSession.
Example (Python)
from livekit.agents import AgentSession
session = AgentSession(
stt="deepgram/nova-3:es", # Spanish for STT
tts="cartesia/sonic-3:esp", # Spanish for TTS
llm="openai/gpt-4o:es" # Spanish for LLM
)
2) Using model objects (explicit)
• Create STT, TTS, or LLM instances from inference and pass a language parameter.
Example (Python)
from livekit.agents import AgentSession, inference
session = AgentSession(
stt=inference.STT(model="deepgram/nova-3", language="es"),
tts=inference.TTS(model="cartesia/sonic-3", language="esp"),
llm=inference.LLM(model="openai/gpt-4o", language="es"),
)
3) Using provider plugins (provider-specific classes)
• Use the plugin’s class and set language there.
Example (Python with Deepgram plugin)
from livekit.plugins import deepgram
session = AgentSession(
stt=deepgram.STT(model="nova-3", language="es"),
# tts / llm as needed
)
Important notes
• Language codes can vary by provider (e.g., Deepgram uses es, some TTS providers use esp). Use the provider’s supported codes for each model.
• For multilingual STT support, some models accept language="multi" and you can also switch languages at runtime with update_options() if your workflow needs dynamic switching.
Would you like:
• Examples for Node.js instead of Python?
• Help picking the correct language code for a specific provider/model?
• An example of switching languages at runtime? Sources: Speech-to-text (STT) models overview | LiveKit Documentation | Cartesia TTS | LiveKit Documentation | ElevenLabs TTS | LiveKit Documentation | Deepgram STT | LiveKit Documentation | Build a multilingual voice agent that automatically switches languages | Li...late-glass-71191
03/05/2026, 6:40 PMtall-airline-16756
03/05/2026, 6:41 PMprovider/model:language for stt, tts, and llm when creating the AgentSession.
Example (Node.js / TypeScript)
import { AgentSession } from '@livekit/agents';
const session = new AgentSession({
stt: "deepgram/nova-3:es", // Spanish for STT
tts: "cartesia/sonic-3:esp", // Spanish for TTS (provider-specific code)
llm: "openai/gpt-4o:es" // Spanish for LLM
});
2) Using model objects (explicit)
• Create STT, TTS, or LLM instances from inference and specify language.
Example (Node.js / TypeScript)
import { AgentSession, inference } from '@livekit/agents';
const session = new AgentSession({
stt: new inference.STT({ model: "deepgram/nova-3", language: "es" }),
tts: new inference.TTS({ model: "cartesia/sonic-3", language: "esp" }),
llm: new inference.LLM({ model: "openai/gpt-4o", language: "es" })
});
3) Using provider plugins (provider-specific classes)
• Use the plugin’s STT/TTS class and set language there.
Example (Node.js)
import { AgentSession } from '@livekit/agents';
import { deepgram } from '@livekit/plugins-deepgram';
const session = new AgentSession({
stt: new deepgram.STT({ model: "nova-3", language: "es" }),
// tts / llm can be set similarly using plugin classes
});
Key notes
• Language codes vary by provider: use the provider’s supported code for each model (e.g., Deepgram uses `es`; some TTS providers use esp).
• There is no single global agent language — set the language per component (STT, TTS, LLM).
• For multilingual STT, some models support language: "multi". You can also change languages at runtime using update_options() on the session if you need dynamic switching.
Would you like a full runnable Node.js snippet showing how to start the session and switch languages at runtime?late-glass-71191
03/05/2026, 7:35 PMtall-airline-16756
03/05/2026, 7:35 PMstreamAdapter — is it already converting microphone audio into the format Infomaniak expects, or do you need help wiring that too?
• Does Infomaniak accept a language parameter (e.g., language: 'fr') in requests, or do you need language selection handled client-side?
While you answer, here are two common approaches you can use depending on how your Infomaniak STT is available.
1) Wrap Infomaniak as a custom STT implementation (recommended if you have an SDK or can call an HTTP API)
• Create a class that implements the interface expected by the voice/agents STT adapter (accepts audio chunks and returns transcriptions). In that class include a language option and pass that option to Infomaniak requests if the API supports it.
• Use that wrapper instance in place of streamAdapter when creating the AgentSession.
Sketch (Node.js/TypeScript)
// pseudocode — adapt to your SDK/API
class InfomaniakSTT {
constructor(opts: { apiKey: string; language?: string }) {
this.apiKey = opts.apiKey;
this.language = opts.language ?? 'fr';
}
// called by the voice agent code to push audio
async acceptAudioChunk(chunk: ArrayBuffer) {
// buffer/convert chunk as Infomaniak requires
// send to Infomaniak API with language=this.language
// handle streaming or batch response
}
// return transcription text (or stream results as your adapter expects)
async getTranscription() { /* ... */ }
}
// then
const infomaniakAdapter = new InfomaniakSTT({ apiKey: process.env.INFOMANIAK_KEY, language: 'fr' });
const session = new voice.AgentSession({
stt: infomaniakAdapter,
llm: new google.LLM({
model: 'gemini-3-flash-preview',
apiKey: process.env.GOOGLE_API_KEY,
language: 'fr' // set LLM language if supported by this wrapper
}),
tts: new elevenlabs.TTS({
voice: { id: 'JBFqnCBsd6RMkjVDRZzb' },
model: 'eleven_multilingual_v2',
apiKey: process.env.ELEVENLABS_API_KEY,
language: 'fr' // provider-specific code (if supported)
}),
vad
});
2) Use an adapter that translates LiveKit audio stream to Infomaniak’s HTTP endpoints (useful if Infomaniak expects full audio files)
• Buffer audio from the live stream into short segments (e.g., 5–10s) and POST them to Infomaniak with a language field.
• Emit returned transcriptions back into the agent as if they came from a streaming STT adapter.
Notes about language handling
• The AgentSession itself doesn’t provide a single global language — set language on each component (STT, TTS, LLM).
• If Infomaniak supports language codes, pass the appropriate code from your wrapper when you call their API. If not, perform language detection or choose a code client-side and send text to the LLM/TTS in that language.
• ElevenLabs and Google may each require different language identifiers. Ensure you use provider-specific codes for TTS/LLM where applicable.
If you share:
• a sample of Infomaniak’s STT request/response (or SDK method names), and
• how streamAdapter is currently implemented (or whether you want me to replace it),
I’ll produce a concrete, runnable Node.js example that wires Infomaniak into your AgentSession and demonstrates setting/changing the language (including runtime switching with an update_options()-style approach if you want that).late-glass-71191
03/05/2026, 7:40 PMapiToken must be set to your Infomaniak API token, either using the argument or by setting the
* INFOMANIAK_API_TOKEN environment variable.
* productId must be set to your Infomaniak product ID, either using the argument or by setting the
* INFOMANIAK_PRODUCT_ID environment variable.
*/
constructor(opts: Partial<InfomaniakSTTOptions> = {}) {
super({ streaming: false, interimResults: false });
this.#opts = {
apiToken: opts.apiToken || process.env.INFOMANIAK_API_TOKEN || '',
productId: opts.productId || process.env.INFOMANIAK_PRODUCT_ID || '',
baseURL: opts.baseURL || defaultInfomaniakSTTOptions.baseURL!,
pollingInterval: opts.pollingInterval || defaultInfomaniakSTTOptions.pollingInterval!,
maxPollingAttempts:
opts.maxPollingAttempts || defaultInfomaniakSTTOptions.maxPollingAttempts!,
language: opts.language || defaultInfomaniakSTTOptions.language!,
};
if (!this.#opts.apiToken) {
throw new Error(
'Infomaniak API token is required, whether as an argument or as $INFOMANIAK_API_TOKEN',
);
}
if (!this.#opts.productId) {
throw new Error(
'Infomaniak Product ID is required, whether as an argument or as $INFOMANIAK_PRODUCT_ID',
);
}
}
async _recognize(buffer: AudioBuffer): Promise<stt.SpeechEvent> {
try {
// Merge all audio frames into a single frame
const mergedBuffer = mergeFrames(buffer);
// Convert to WAV format
const wavBuffer = this.#createWav(mergedBuffer);
// Upload and create transcription batch
const batchId = await this.#createTranscription(wavBuffer);
// Poll for completion
const transcriptionText = await this.#pollForResult(batchId);
// Return speech event
return {
type: stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives: [
{
text: transcriptionText,
language: '',
startTime: 0,
endTime: 0,
confidence: 1.0,
},
],
};
} catch (error) {
console.error('Infomaniak STT error:', error);
throw error;
}
}
/**
* Create WAV file header and combine with audio data
*/
#createWav(frame: AudioFrame): Buffer {
const bitsPerSample = 16;
const byteRate = (frame.sampleRate * frame.channels * bitsPerSample) / 8;
const blockAlign = (frame.channels * bitsPerSample) / 8;
const header = Buffer.alloc(44);
// RIFF chunk descriptor
header.write('RIFF', 0);
header.writeUInt32LE(36 + frame.data.byteLength, 4);
header.write('WAVE', 8);
// fmt sub-chunk
header.write('fmt ', 12);
header.writeUInt32LE(16, 16); // Subchunk1Size
header.writeUInt16LE(1, 20); // AudioFormat (1 = PCM)
header.writeUInt16LE(frame.channels, 22);
header.writeUInt32LE(frame.sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(16, 34); // BitsPerSample
// data sub-chunk
header.write('data', 36);
header.writeUInt32LE(frame.data.byteLength, 40);
return Buffer.concat([header, Buffer.from(frame.data.buffer)]);
}
/**
* Create transcription batch on Infomaniak
*/
async #createTranscription(audioBuffer: Buffer): Promise<string> {
const form = new FormData();
form.append('file', audioBuffer, {
filename: 'audio.wav',
contentType: 'audio/wav',
});
form.append('model', 'whisper');
const url = `${this.#opts.baseURL}/1/ai/${this.#opts.productId}/openai/audio/transcriptions`;
try {
const response = await axios.post<TranscriptionResponse>(url, form, {
headers: {
Authorization: Bearer ${this.#opts.apiToken},
...form.getHeaders(),
},
});
if (!response.data.batch_id) {
throw new Error('No batch_id received from Infomaniak API');
}
console.log([InfomaniakSTT] Transcription batch created: ${response.data.batch_id});
return response.data.batch_id;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('[InfomaniakSTT] Axios error details:', {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
headers: error.response?.headers,
});
} else {
console.error('[InfomaniakSTT] Non-axios error:', error);
}
throw new Error(
Failed to create transcription: ${error instanceof Error ? error.message : String(error)},
);
}
}
/**
* Poll for transcription result
*/
async #pollForResult(batchId: string): Promise<string> {
const url = `${this.#opts.baseURL}/1/ai/${this.#opts.productId}/results/${batchId}`;
for (let attempt = 0; attempt < (this.#opts.maxPollingAttempts as number); attempt++) {
try {
const response = await axios.get<BatchStatusResponse>(url, {
headers: {
Authorization: Bearer ${this.#opts.apiToken},
},
});
const status = response.data.status;
console.log(
[InfomaniakSTT] Batch ${batchId} status: ${status} (attempt ${attempt + 1}/${this.#opts.maxPollingAttempts}),
);
if (status === 'success') {
return await this.#downloadResult(batchId, response.data);
} else if (status === 'failed') {
throw new Error(Transcription batch ${batchId} failed);
}
await this.#sleep(this.#opts.pollingInterval as number);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(
[InfomaniakSTT] Error polling batch ${batchId}:,
error.response?.data || error.message,
);
}
throw error;
}
}
throw new Error(
Transcription timed out after ${this.#opts.maxPollingAttempts} attempts (${((this.#opts.maxPollingAttempts as number) * (this.#opts.pollingInterval as number)) / 1000}s),
);
}
/**
* Download transcription result
*/
async #downloadResult(batchId: string, statusData: BatchStatusResponse): Promise<string> {
// Data is directly in the status response
if (statusData.data) {
console.log([InfomaniakSTT] Transcription received in status response);
return this.#parseTranscriptionData(statusData.data);
}
// Download from provided URL
if (statusData.url) {
try {
console.log([InfomaniakSTT] Downloading from URL: ${statusData.url});
const response = await axios.get(statusData.url, {
headers: {
Authorization: Bearer ${this.#opts.apiToken},
},
});
return this.#parseTranscriptionData(response.data);
} catch (error) {
console.error('[InfomaniakSTT] Error downloading from URL:', error);
}
}
// Try the download endpoint
const downloadUrl = `${this.#opts.baseURL}/1/ai/${this.#opts.productId}/results/${batchId}/download`;
try {
console.log([InfomaniakSTT] Downloading from endpoint: ${downloadUrl});
const response = await axios.get(downloadUrl, {
headers: {
Authorization: Bearer ${this.#opts.apiToken},
},
});
return this.#parseTranscriptionData(response.data);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(
'[InfomaniakSTT] Error downloading from endpoint:',
error.response?.data || error.message,
);
}
throw error;
}
}
/**
* Parse transcription data from various possible response formats
*/
#parseTranscriptionData(data: any): string {
// Handle string response
if (typeof data === 'string') {
return data;
}
// Handle object with 'text' property
if (data && typeof data === 'object' && data.text) {
return data.text;
}
// Handle object with 'transcription' property
if (data && typeof data === 'object' && data.transcription) {
return data.transcription;
}
// If it's an object, try to stringify and use as text
if (data && typeof data === 'object') {
console.warn('[InfomaniakSTT] Unexpected response format, using JSON string:', data);
return JSON.stringify(data);
}
throw new Error(Unexpected transcription data format: ${JSON.stringify(data)});
}
/**
* Sleep utility for polling
*/
#sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* This method throws an error; streaming is unsupported on Infomaniak STT.
*/
stream(): stt.SpeechStream {
throw new Error('Streaming is not supported on Infomaniak STT');
}
}tall-airline-16756
03/05/2026, 7:41 PMlanguage option to your InfomaniakSTT class (you already have it in the options). Ensure that option is sent to Infomaniak when creating the transcription batch.
• Pass the desired language when you construct InfomaniakSTT and set LLM/TT S language consistently in the AgentSession.
• Optionally support runtime language changes by exposing a setter or updateOptions() that updates this.#opts.language.
Concrete changes
1) Send the language when creating the transcription request In your #createTranscription method you currently append model: 'whisper'. Add the language param to the form if Infomaniak supports it:
// inside #createTranscription
form.append('model', 'whisper');
// add language field from this.#opts.language
if (this.#opts.language) {
form.append('language', this.#opts.language);
}
This ensures Infomaniak receives the requested language (e.g., 'fr', 'en') with the upload.
2) Pass the language when constructing InfomaniakSTT When creating the instance, include language explicitly so it doesn’t rely only on defaults:
const infomaniakSTT = new InfomaniakSTT({
apiToken: process.env.INFOMANIAK_API_TOKEN!,
productId: process.env.INFOMANIAK_PRODUCT_ID!,
pollingInterval: 500,
maxPollingAttempts: 60,
language: 'fr' // set desired language here
});
const streamAdapter = new stt.StreamAdapter(infomaniakSTT as stt.STT, vad);
const session = new voice.AgentSession({
stt: streamAdapter,
llm: new google.LLM({
model: 'gemini-3-flash-preview',
apiKey: process.env.GOOGLE_API_KEY,
language: 'fr' // set LLM language where supported
}),
tts: new elevenlabs.TTS({
voice: { id: 'JBFqnCBsd6RMkjVDRZzb' },
model: 'eleven_multilingual_v2',
apiKey: process.env.ELEVENLABS_API_KEY,
language: 'fr' // provider-specific code if supported
}),
vad
});
3) Expose runtime language updates (optional) If you need to change language during a session, add a method on InfomaniakSTT to update the option:
setLanguage(lang: string) {
this.#opts.language = lang;
}
Then call it from your app when you want to switch languages:
infomaniakSTT.setLanguage('en');
If you want the entire AgentSession to switch languages at runtime, call the provider-specific update for LLM/TT S as supported by their SDKs (or recreate those components) and update the STT adapter as above.
4) Small robustness suggestions
• Validate the language code early and document which codes Infomaniak expects (e.g., 'fr', 'en'). If Infomaniak uses different codes, map them in your wrapper.
• When polling, log the language with each request (helps debug wrong-language transcriptions).
• If Infomaniak returns language metadata in the response, populate the language field in your returned SpeechEvent alternative:
alternatives: [{
text: transcriptionText,
language: this.#opts.language || '',
...
}]
Questions to confirm next steps
• Does Infomaniak accept a language field in the transcription POST (your API docs)? If not, we should implement client-side selection or detection before sending audio.
• Do you want an example that shows runtime switching of language across STT, LLM, and TTS together?
If you confirm Infomaniak accepts language in the POST, I can provide a small patched file showing the exact insertion points and a runnable Node.js snippet that sets and changes language at runtime.late-glass-71191
03/05/2026, 7:44 PMtall-airline-16756
03/05/2026, 7:45 PMlanguage option for your LLM and TTS instances to Italian when constructing the AgentSession.
• Remove any runtime language-update paths (or simply don't call them) so nothing in your app changes language after startup.
• Populate the returned SpeechEvent language field so downstream code sees the language is Italian.
Concrete changes for your code
1) Default and constructor: force Italian Change the default language and ensure the constructor always uses it (no user-overridable fallback):
const defaultInfomaniakSTTOptions: Partial<InfomaniakSTTOptions> = {
baseURL: '<https://api.infomaniak.com>',
pollingInterval: 500,
maxPollingAttempts: 60,
language: 'it', // set Italian as the default
};
constructor(opts: Partial<InfomaniakSTTOptions> = {}) {
super({ streaming: false, interimResults: false });
this.#opts = {
apiToken: opts.apiToken || process.env.INFOMANIAK_API_TOKEN || '',
productId: opts.productId || process.env.INFOMANIAK_PRODUCT_ID || '',
baseURL: opts.baseURL || defaultInfomaniakSTTOptions.baseURL!,
pollingInterval: opts.pollingInterval || defaultInfomaniakSTTOptions.pollingInterval!,
maxPollingAttempts: opts.maxPollingAttempts || defaultInfomaniakSTTOptions.maxPollingAttempts!,
language: 'it', // force Italian regardless of incoming opts
};
...
}
If you prefer to allow configuration via env variable but disallow runtime changes, use:
language: opts.language || process.env.INFOMANIAK_LANGUAGE || 'it',
and do not expose a setLanguage method.
2) Send language to Infomaniak with every transcription In #createTranscription append language to the form so Infomaniak always receives Italian:
form.append('model', 'whisper');
form.append('language', this.#opts.language); // will be 'it'
3) Return language in SpeechEvent When returning the final transcript, set the language field so downstream components know it’s Italian:
return {
type: stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives: [{
text: transcriptionText,
language: this.#opts.language || 'it',
startTime: 0,
endTime: 0,
confidence: 1.0,
}],
};
4) Construct AgentSession with Italian for LLM and TTS Set LLM and TTS language at creation. Use provider-specific codes (Google may accept it, ElevenLabs may use it or another identifier depending on the model):
const infomaniakSTT = new InfomaniakSTT({
apiToken: process.env.INFOMANIAK_API_TOKEN!,
productId: process.env.INFOMANIAK_PRODUCT_ID!,
language: 'it', // Italian fixed
pollingInterval: 500,
maxPollingAttempts: 60,
});
const streamAdapter = new stt.StreamAdapter(infomaniakSTT as stt.STT, vad);
const session = new voice.AgentSession({
stt: streamAdapter,
llm: new google.LLM({
model: 'gemini-3-flash-preview',
apiKey: process.env.GOOGLE_API_KEY,
language: 'it', // LLM language fixed to Italian
}),
tts: new elevenlabs.TTS({
voice: { id: 'JBFqnCBsd6RMkjVDRZzb' },
model: 'eleven_multilingual_v2',
apiKey: process.env.ELEVENLABS_API_KEY,
language: 'it', // set to provider-specific Italian code if required
}),
vad,
});
5) Remove or avoid runtime language switching
• Do not call any setLanguage, updateOptions(), or similar methods that would alter language at runtime.
• If you previously added a setLanguage on InfomaniakSTT for testing, remove it or ensure it is never invoked.
6) Verify provider language codes
• Confirm Infomaniak expects it (or another code) for the transcription endpoint. If their API uses a different Italian code, use that.
• Confirm Google and ElevenLabs accept it for their language option; if a provider expects a different code (e.g., ita), adjust accordingly.
Optional: add guard-rails
• Log and assert language at startup to ensure all components are Italian:
console.log('Agent languages: STT=', infomaniakSTT.#opts.language, 'LLM=it', 'TTS=it');
• Return an error or throw if any component is constructed without the expected Italian language to prevent misconfiguration.
Do you want me to:
• Patch your InfomaniakSTT file with the exact diffs applied, or
• Provide a complete runnable Node.js snippet (including imports and environment setup) that demonstrates the fixed-Italian configuration?late-glass-71191
03/05/2026, 9:00 PMtall-airline-16756
03/05/2026, 9:01 PMlate-glass-71191
03/05/2026, 9:08 PMtall-airline-16756
03/05/2026, 9:08 PMFrom header value. The logs showed "fromUser": "+" which is not a valid configured caller ID.
1. Configure a valid caller ID in your trunk settings
◦ Use a Twilio DID assigned to your account and configured on the SIP trunk, or
◦ Verify the outbound number in Twilio (Verified Caller ID) and ensure the trunk allows that verified number.
1. Update LiveKit SIP participant creation payload
◦ When calling SIP.CreateSIPParticipant, set fromUser (caller ID) to the valid DID or verified caller ID configured in Twilio. Example: "+1XXXXXXXXXX".
1. Re-run and capture a SIP trace
◦ After updating the From header, reproduce the call and capture the INVITE/response exchange (full SIP trace) to confirm Twilio accepts the INVITE (you should see 100/180/200 as appropriate rather than 403).
1. Verify Twilio trunk routing and permissions
◦ Ensure the SIP trunk ST_NjwdW22CmFhc is configured to accept outbound calls to the destination region and that your account is not blocked or rate-limited.
1. Optional: add validation / guard rails
◦ Add startup checks in your app to assert fromUser is a configured DID to avoid runtime 403s.
◦ Log full From header and trunk id in the SIP request path so future failures are easier to trace.
What to check in parallel
• Twilio Console logs for the rejected call — Twilio will often include reason details and the exact caller ID it rejected.
• Any NAT/transport differences if you see other call failures, but for this case the 403 is authoritative: it’s a caller-ID policy rejection at the provider.
If you want, I can:
• Show exactly where to set fromUser in your CreateSIPParticipant payload (paste the snippet you use to call the API), or
• Help interpret a pasted SIP INVITE/response exchange (full trace) to confirm headers and the side that sent CANCEL.
Which would you like me to do next?late-glass-71191
03/05/2026, 9:13 PMtall-airline-16756
03/05/2026, 9:14 PMcreateSipParticipant and the logs showing the 403 from Twilio.
What’s happening
• Twilio rejected the outbound INVITE with 403: "Caller ID is unauthorized." The logs and your earlier output show fromUser / fromNumber was empty or set to "+", which is not a valid caller ID for Twilio. Twilio requires the From/Caller-ID to be either a DID you purchased on Twilio or a phone number you’ve verified with them.
What you should change
• Supply a valid caller ID when creating the SIP participant. Use a Twilio DID assigned to your account (preferred) or a verified outbound caller ID that Twilio recognizes.
Example change
• Replace the empty fromNumber with a valid number string:
const sipParticipant = await this.sipClient.createSipParticipant(
trunk.trunkId,
toNumber,
roomName,
{
participantMetadata: JSON.stringify({ agent_id: '' }),
fromNumber: '+1XXXXXXXXXX', // <-- set to your Twilio DID or verified number
waitUntilAnswered: true,
krispEnabled: true,
},
);
Quick checklist to verify
• Confirm the number you put in fromNumber is:
◦ A Twilio DID purchased on the same account as the trunk, or
◦ A caller ID you’ve verified in the Twilio console.
• Check Twilio Console for the rejected call entry — it will show the exact From header Twilio received and confirm the reason.
• Ensure the trunk configuration allows outbound calls from that DID/verified caller ID.
If you want, I can:
• Show how to read the exact From header from a SIP INVITE trace if you paste it.
• Provide a short snippet that validates fromNumber exists and throws a clear error before sending the CreateSIPParticipant request.
Which would you like next?late-glass-71191
03/05/2026, 9:16 PMtall-airline-16756
03/05/2026, 9:16 PMfromNumber / From header) is missing or invalid. That causes the 403: "Caller ID is unauthorized." I’ll summarize what’s happening and give clear steps to fix it plus a small code validation you can add.
Why this is happening
• Twilio requires the outbound Caller ID to be either:
◦ A Twilio DID (a phone number you purchased on the same Twilio account), or
◦ A phone number you verified as an outbound caller ID in the Twilio Console.
• When LiveKit sends an INVITE with an empty or invalid From (your logs showed "fromUser": "+"), Twilio rejects the INVITE with 403 and the SIP leg never connects.
How to fix it (steps)
1. Pick a valid caller ID
◦ Preferred: Use a Twilio DID purchased on the same account as your SIP trunk.
◦ Alternative: Use a phone number you’ve verified in Twilio as an allowed outbound caller ID.
1. Set the caller ID in your createSipParticipant call
◦ Populate fromNumber with the valid E.164 phone number string (for example, +14155551212).
Example change
const sipParticipant = await this.sipClient.createSipParticipant(
trunk.trunkId,
toNumber,
roomName,
{
participantMetadata: JSON.stringify({ agent_id: '' }),
fromNumber: '+1XXXXXXXXXX', // <-- Twilio DID or verified number
waitUntilAnswered: true,
krispEnabled: true,
},
);
1. Verify Twilio trunk and caller ID config
◦ Confirm the DID is attached to the SIP trunk ST_NjwdW22CmFhc or permitted for outbound on that trunk.
◦ If using a verified number, ensure the trunk/account policy allows that verified Caller ID.
1. Reproduce and inspect logs
◦ After updating fromNumber, retry. You should see Twilio respond with provisional responses (100 / 180) or 200 rather than 403.
◦ Check Twilio Console for the rejected call entry — it shows the From value Twilio received and any additional rejection details.
Optional: Add a preflight validation in your code
• Validate fromNumber before calling createSipParticipant and fail fast with a helpful message:
if (!fromNumber || fromNumber === '+') {
throw new Error('Invalid fromNumber: set a Twilio DID or verified caller ID before creating SIP participant');
}
If you want, I can:
• Show exactly how to read the full SIP INVITE if you paste a SIP trace, to confirm the From header.
• Provide a snippet that programmatically checks Twilio Console (or your config) for the DID before creating the SIP participant.
• Help craft a small startup assertion to prevent misconfiguration.
Which of these would be most helpful right now?late-glass-71191
03/05/2026, 9:45 PMtall-airline-16756
03/05/2026, 9:45 PMlate-glass-71191
03/05/2026, 9:48 PMtall-airline-16756
03/05/2026, 9:48 PMlate-glass-71191
03/05/2026, 9:50 PMtall-airline-16756
03/05/2026, 9:51 PMlate-glass-71191
03/05/2026, 9:52 PMtall-airline-16756
03/05/2026, 9:53 PMlate-glass-71191
03/05/2026, 9:53 PMtall-airline-16756
03/05/2026, 9:53 PMlate-glass-71191
03/05/2026, 9:59 PMInitiating call from ${fromNumber} to ${toNumber});
const agent = await prisma.agents.findUnique({
where: {
id: agentId,
},
});
if (!agent) {
throw new Error('Agent not found');
}
if (agent.status !== 'ACTIVE') {
throw new Error(Agent is not active. Current status: ${agent.status});
}
const trunk = await prisma.outboundTrunk.findUnique({
where: {
trunkId: trunkId,
},
});
if (!trunk) {
throw new Error('Trunk not found');
}
if (!trunk?.numbers.includes(fromNumber)) {
throw new Error(Number ${fromNumber} not configured on trunk);
}
const roomName = `outbound-${agentId}-${Date.now()}`;
console.log(Creating room: ${roomName});
await this.roomClient.createRoom({
name: roomName,
emptyTimeout: 300,
maxParticipants: 10,
});
console.log(Room created: ${roomName});
console.log(Dispatching agent ${agent.name} to room ${roomName});
const agentDispatch = await this.agentDispatchClient.createDispatch(roomName, agent.name, {
metadata: JSON.stringify({
agent_id: agentId,
trunk_id: trunkId,
call_type: 'outbound',
from_number: fromNumber,
to_number: toNumber,
}),
});
console.log(Agent dispatched: ${agentDispatch.id});
console.log(Creating SIP participant for ${toNumber});
const sipParticipant = await this.sipClient.createSipParticipant(
trunk.trunkId,
toNumber,
roomName,
{
participantMetadata: JSON.stringify({
agent_id: '',
}),
fromNumber: fromNumber,
waitUntilAnswered: true,
krispEnabled: true,
},
);
console.log(SIP participant created: ${sipParticipant.participantId});
return {
success: true,
dispatch: agentDispatch,
sipParticipant,
roomName,
message: options?.waitUntilAnswered ? 'Call answered and connected' : 'Call initiated',
};
} catch (error: any) {
console.error('Failed to initiate call:', error);
if (error.metadata) {
const sipStatusCode = error.metadata['sip_status_code'];
const sipStatus = error.metadata['sip_status'];
console.error(SIP Error: ${sipStatusCode} - ${sipStatus});
throw new Error(SIP Error: ${sipStatusCode} - ${sipStatus || error.message});
}
throw error;
}
}
will retries resolve the isuetall-airline-16756
03/05/2026, 10:00 PMlate-glass-71191
03/05/2026, 10:00 PM