late-glass-71191
12/18/2025, 6:12 PMtall-airline-16756
12/18/2025, 6:12 PMtall-airline-16756
12/18/2025, 6:12 PMlate-glass-71191
12/18/2025, 7:28 PMtall-airline-16756
12/18/2025, 7:28 PMlate-glass-71191
12/18/2025, 7:34 PMtall-airline-16756
12/18/2025, 7:34 PMPORT=3000
LIVEKIT_API_URL=<https://your-livekit.example>
LIVEKIT_API_KEY=lk_api_key
LIVEKIT_API_SECRET=lk_api_secret
TWILIO_ACCOUNT_SID=ACxxxx
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_TRUNK_DOMAIN=<http://my-trunk.pstn.twilio.com|my-trunk.pstn.twilio.com>
Install:
npm install express dotenv twilio node-fetch@2 livekit-server-sdk
npm install -D typescript @types/express @types/node ts-node
tsconfig: standard target ES2020, module commonjs.
src/twilio.ts
import twilio from "twilio";
import dotenv from "dotenv";
dotenv.config();
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
export async function verifyNumberProvisioned(phoneNumber: string): Promise<boolean> {
// Simple check: list incoming phone numbers to see if provisioned
const list = await client.incomingPhoneNumbers.list({ phoneNumber, limit: 1 });
return list.length > 0;
}
src/livekit.ts
import fetch from "node-fetch";
import dotenv from "dotenv";
dotenv.config();
const BASE = process.env.LIVEKIT_API_URL!;
const API_KEY = process.env.LIVEKIT_API_KEY!;
const API_SECRET = process.env.LIVEKIT_API_SECRET!;
// Basic helper for LiveKit admin REST calls using API key/secret as basic auth
async function lkRequest(path: string, method = "GET", body?: any) {
const url = `${BASE}/admin${path}`;
const headers: any = {
Authorization: "Basic " + Buffer.from(`${API_KEY}:${API_SECRET}`).toString("base64"),
"Content-Type": "application/json",
};
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`LiveKit ${method} ${path} failed: ${res.status} ${txt}`);
}
return res.json();
}
export async function ensureInboundTrunk(name: string, numbers: string[]) {
// Create a trunk with provided numbers. LiveKit may have specific fields; this is illustrative.
const body = {
name,
numbers,
krispEnabled: true,
};
return lkRequest("/sip/inbound", "POST", body);
}
export async function ensureDispatchRule(name: string, trunkId: string, phoneNumber: string, roomPrefix = "call-") {
// Create a dispatch rule that matches the called number and routes to rooms with prefix.
const body = {
name,
trunkIds: [trunkId],
match: {
calledNumber: phoneNumber
},
roomConfig: {
type: "room_prefix",
prefix: roomPrefix
}
};
return lkRequest("/sip/dispatch", "POST", body);
}
export async function createRoom(roomName: string) {
return lkRequest("/rooms", "POST", { name: roomName });
}
// Generate an Access Token for the agent using livekit-server-sdk
import { AccessToken } from "livekit-server-sdk";
export function createAgentToken(identity: string, room: string) {
const at = new AccessToken(API_KEY, API_SECRET, { identity });
at.addGrant({ roomJoin: true, room });
return at.toJwt();
}
src/index.ts
import express from "express";
import dotenv from "dotenv";
import { verifyNumberProvisioned } from "./twilio";
import { ensureInboundTrunk, ensureDispatchRule, createRoom, createAgentToken } from "./livekit";
dotenv.config();
const app = express();
app.use(express.json());
/**
* POST /assign-number
* body: { agentId, phoneNumber }
* Creates/ensures trunk + dispatch rule mapping phoneNumber -> room prefix. Returns agent token + room.
*/
<http://app.post|app.post>("/assign-number", async (req, res) => {
try {
const { agentId, phoneNumber } = req.body as { agentId: string; phoneNumber: string };
if (!agentId || !phoneNumber) return res.status(400).json({ error: "agentId and phoneNumber required" });
// Optional: check Twilio provisioning
const ok = await verifyNumberProvisioned(phoneNumber);
if (!ok) return res.status(400).json({ error: "phone number not provisioned in Twilio" });
// Ensure inbound trunk exists (id returned from LiveKit)
const trunk = await ensureInboundTrunk(`twilio-trunk-${phoneNumber}`, [phoneNumber]);
const trunkId = trunk.id || trunk.sipTrunkId || trunk.trunkId;
// Create dispatch rule that routes calls for this number to rooms with prefix call-
await ensureDispatchRule(`dispatch-${phoneNumber}`, trunkId, phoneNumber, "call-");
// Create a new room for the agent (or you could use agent-specific room naming)
const roomName = `call-${agentId}-${Date.now()}`;
await createRoom(roomName);
// Generate agent token to join the room
const token = createAgentToken(agentId, roomName);
return res.json({ success: true, room: roomName, token });
} catch (err: any) {
console.error(err);
return res.status(500).json({ error: err.message || String(err) });
}
});
app.listen(process.env.PORT || 3000, () => {
console.log("listening");
});
How it fits your frontend flow
• Frontend creates an agent identity and calls POST /assign-number with agentId and desired phoneNumber.
• Backend ensures the Twilio number is on a LiveKit inbound trunk and that a dispatch rule routes calls for that number into a room namespace.
• Backend creates a room and returns an agent access token so the agent can join the room and accept calls routed to that number.
Next steps I can provide
• More precise LiveKit request shapes if you want exact field names returned by your LiveKit version.
• Example frontend code to call these endpoints and join the room with the token.
Want those next steps?late-glass-71191
12/18/2025, 10:11 PMtall-airline-16756
12/18/2025, 10:12 PMlate-glass-71191
12/18/2025, 10:21 PMtall-airline-16756
12/18/2025, 10:22 PMensureInboundTrunk call working.
1) Use the correct LiveKit server URL
• LIVEKIT_HOST / LIVEKIT_URL must point to your project’s server endpoint, e.g.:
◦ https://your-project.livekit.cloud
◦ or wss://your-project.livekit.cloud (for WS connections)
• Do NOT use https://cloud.livekit.io (that’s the dashboard and returns HTML).
2) Use the Server SDK (don’t call /sip/inbound manually)
• The Node/TS Server SDK routes requests to the correct Twirp endpoints for SIP.
• Example init:
◦ const sipClient = new SipClient(process.env.LIVEKIT_URL!, process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!);
3) Env vars example
• LIVEKIT_URL=https://your-project.livekit.cloud
• LIVEKIT_API_KEY=lk_xxxYourApiKeyxxx
• LIVEKIT_API_SECRET=yourLongSecret==
(Keep the secret on the backend only.)
4) Correct SDK call pattern (TypeScript)
• List existing trunks, then create if missing:
const sipClient = new SipClient(process.env.LIVEKIT_URL!, process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!);
// check existing
const trunks = await sipClient.listSipInboundTrunk();
const existing = trunks.find(t => t.name === agentId);
if (existing) return existing;
// create
const trunk = await sipClient.createSipInboundTrunk(agentId, [phoneNumber], { metadata: `Agent:${agentId}`, krispEnabled: true });
5) If you still get 404
• Log the exact LIVEKIT_URL your server is using.
• Confirm API key/secret are correct and have SIP permissions.
• Verify your server can reach the URL (curl the base URL from your backend environment).
• Share the exact LIVEKIT_URL (redact secrets) and the error text and I’ll point out the specific mis-match.
Want me to review the LIVEKIT_URL you’re using (paste it here) and one sample request/stack trace?
Sources: SIP APIs | LiveKit docs | SIP trunk setup | LiveKit docs | Create and configure a Twilio SIP trunk | LiveKit docslate-glass-71191
12/18/2025, 10:28 PMtall-airline-16756
12/18/2025, 10:28 PMwss://<project>.livekit.cloud (or your self‑hosted server URL) for LIVEKIT_URL and runs on the backend.
Install:
npm install livekit-server-sdk dotenv
npm install -D typescript @types/node
.env (example)
LIVEKIT_URL=<wss://your-project.livekit.cloud>
LIVEKIT_API_KEY=lk_ABC123...
LIVEKIT_API_SECRET=your_secret_here
src/sip.ts
import dotenv from "dotenv";
dotenv.config();
import { SipClient } from "livekit-server-sdk";
const LIVEKIT_URL = process.env.LIVEKIT_URL!;
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY!;
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET!;
if (!LIVEKIT_URL || !LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) {
throw new Error("Set LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET in .env");
}
const sipClient = new SipClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
export async function listInboundTrunks() {
return sipClient.listSipInboundTrunk();
}
export async function ensureInboundTrunk(name: string, numbers: string[]) {
// Check existing
const trunks = await listInboundTrunks();
const existing = trunks.find(t => t.name === name || (t.numbers || []).some((n: string) => numbers.includes(n)));
if (existing) return existing;
// Create inbound trunk
const trunk = await sipClient.createSipInboundTrunk(name, numbers, {
// optional fields
krispEnabled: true,
metadata: `created-for:${name}`,
});
return trunk;
}
export async function createDispatchRule(name: string, trunkIds: string[], calledNumber: string, roomPrefix = "call-") {
// dispatchRule "rule" arg shape may vary by SDK version; this matches common usage
const ruleReq = {
rule: {
type: "individual",
roomPrefix,
},
options: {
name,
trunkIds,
match: {
calledNumber,
},
},
};
// SDK provides createSipDispatchRule; adjust call if your SDK version uses different signature
const dispatch = await sipClient.createSipDispatchRule(
// first arg: rule selector/definition
{
type: "individual",
roomPrefix,
},
// second arg: dispatch options (name, trunkIds, match, roomConfig)
{
name,
trunkIds,
match: { calledNumber },
roomConfig: {
// optional: assign agents here or use your agent dispatch flow
type: "room_prefix",
prefix: roomPrefix,
},
}
);
return dispatch;
}
src/index.ts (example usage)
import { ensureInboundTrunk, createDispatchRule } from "./sip";
async function main() {
const agentId = "agent-123";
const phoneNumber = "+15105550100";
// ensure trunk
const trunk = await ensureInboundTrunk(`trunk-${agentId}`, [phoneNumber]);
// trunk id field names can vary (sipTrunkId / id). Normalize:
const trunkId = (trunk.sipTrunkId || trunk.id || trunk.trunkId) as string;
console.log("trunkId:", trunkId);
// create dispatch rule that routes calls to rooms with prefix 'call-'
const dispatch = await createDispatchRule(`dispatch-${agentId}`, [trunkId], phoneNumber, "call-");
console.log("dispatch:", dispatch);
}
main().catch(err => {
console.error("error:", err);
process.exit(1);
});
Important notes and troubleshooting
• LIVEKIT_URL must be your project server URL (example: <wss://your-project.livekit.cloud>). Do NOT use <https://cloud.livekit.io> (dashboard) — that returns HTML (404).
• Use the Server SDK (shown) so the correct Twirp endpoints are used; calling dashboard REST paths will return HTML and 404.
• Trunk and dispatch API shapes can change between SDK versions; if you get signature/type errors, tell me your livekit-server-sdk version and I’ll adapt the call shapes.
• If you still see 404: paste the exact LIVEKIT_URL (redact secrets) and the error and I’ll check for URL/permission issues.
Would you like the snippet adapted to also create the agent room and return an access token?
Sources: SIP APIs | LiveKit docs | SIP inbound trunk | LiveKit docs | SIP dispatch rule | LiveKit docs | Agents telephony integration | LiveKit docslate-glass-71191
12/18/2025, 10:29 PM${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}).toString('base64');
const headers: any = {
Authorization: Bearer ${auth},
'Content-Type': 'application/json',
};
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(body) ?? undefined
});
if (!response.ok) {
const text = await response.text();
throw new Error(LiveKit ${method} ${path} failed: ${response.status} ${text});
}
return response.json();
}
Creating LiveKit SIP trunk...
Creating SIP trunk: trunk-2bc149bb-dc2b-4d1e-a6d2-e227f0de587a-18665638654
Failed to create inbound trunk: Error: LiveKit POST /sip/inbound failed: 401 invalid authorization token
at LiveKitService.lkRequest (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts4113)
at process.processTicksAndRejections (nodeinternal/process/task queues105:5)
at async LiveKitService.ensureInboundTrunk (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts6521)
at async <anonymous> (/home/anirban-singha/Documents/agent-starter-node/src/routers/agent.router.ts9221)
LiveKit setup failed, rolling back agent creation: Error: Failed to create LiveKit SIP trunk
at LiveKitService.ensureInboundTrunk (/home/anirban-singha/Documents/agent-starter-node/src/services/livekitService.ts7313)
at process.processTicksAndRejections (nodeinternal/process/task queues105:5)
at async <anonymous> (/home/anirban-singha/Documents/agent-starter-node/src/routers/agent.router.ts9221)
what is going wrong heretall-airline-16756
12/18/2025, 10:30 PMconst auth = Buffer.from(`${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}`).toString('base64');
const headers = {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
};
2) Ensure LIVEKIT_API_URL is the server API URL (not the dashboard). Example values:
• wss://your-project.livekit.cloud (SDK/gRPC over websocket)
• https://your-project.livekit.cloud (for admin REST calls at /admin paths)
Do NOT use https://cloud.livekit.io (that returns dashboard HTML and caused the 404).
3) Prefer the Server SDK for SIP operations
• Example init:
const sipClient = new SipClient(process.env.LIVEKIT_URL!, process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!);
• Then call sipClient.createSipInboundTrunk(...) which uses the correct Twirp endpoints and auth.
If you want, paste your LIVEKIT_API_URL (redact secrets) and the exact code where you set LIVEKIT_API_URL / KEY / SECRET and I’ll point out any mismatches.late-glass-71191
12/19/2025, 7:51 AMtall-airline-16756
12/19/2025, 7:52 AMsip_admin grant.
1) Generate a SIP admin token (server-side) — example TypeScript:
import { AccessToken } from "livekit-server-sdk";
const at = new AccessToken(process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!, {
identity: "admin",
ttl: "10m",
});
at.addGrant({ sip_admin: true });
const token = at.toJwt();
console.log(token);
2) Postman request
• Method: POST
• URL:
https://<your-livekit-domain>/twirp/livekit.SIP/CreateSIPInboundTrunk (replace <your-livekit-domain> with your project URL, e.g. your-project.livekit.cloud)
• Headers:
◦ Authorization: Bearer <token-from-step-1>
◦ Content-Type: application/json
• Body (raw JSON):
{
"name": "My Inbound Trunk",
"numbers": [
"+15105550100"
],
"krispEnabled": true,
"metadata": "Trunk for support line",
"authUsername": "sipuser",
"authPassword": "sippass"
}
3) curl equivalent
curl -X POST \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name":"My Inbound Trunk","numbers":["+15105550100"],"krispEnabled":true,"metadata":"Trunk for support","authUsername":"sipuser","authPassword":"sippass"}' \
"https://<your-livekit-domain>/twirp/livekit.SIP/CreateSIPInboundTrunk"
Notes & troubleshooting
• Use your LiveKit project server domain (e.g. your-project.livekit.cloud). Do NOT use cloud.livekit.io (dashboard) — that returns HTML/404.
• The Authorization header must be a JWT created with your API key/secret and include sip_admin: true.
• If you get 401 invalid authorization token:
◦ Confirm the token was created with the correct API key/secret and includes sip_admin.
◦ Confirm the token wasn’t expired.
• If you get 404 HTML page: your URL is pointing at the dashboard; switch to your project server URL.
• After success you’ll receive a JSON response with sipTrunkId (or id) and trunk info.
If you want, I can:
• Export this as a Postman collection JSON for import, or
• Show the exact request body fields for your LiveKit SDK version if you paste the SDK version you’re using. Which do you prefer? Sources: SIP inbound trunk | LiveKit docslate-glass-71191
12/19/2025, 8:22 AMtall-airline-16756
12/19/2025, 8:22 AMtype: 'individual' and roomPrefix (not dispatchRuleIndividual). Replace your first argument with the correct shape. Example:
const dispatch = await this.sipClient.createSipDispatchRule(
// rule spec
{
type: 'individual',
roomPrefix,
// pin?: '1234' // optional
},
// options
{
name: ruleName,
trunkIds: [trunkId],
hidePhoneNumber: false,
metadata: JSON.stringify({
agentId,
phoneNumber,
createdAt: new Date().toISOString(),
}),
attributes: {
agent_id: agentId,
phone_number: phoneNumber,
},
}
);
If you need a direct/room-targeted rule instead, use:
{ type: 'direct', roomName: 'support-room', pin: '1234' }
If you still get a type error, tell me your livekit-server-sdk version and I’ll adapt the exact call signatures.
Sources: SIP dispatch rule | LiveKit docs | SIP APIs | LiveKit docs | server-sdk-js | livekit/livekit_composite | DeepWikilate-glass-71191
12/19/2025, 9:09 AMtall-airline-16756
12/19/2025, 9:10 AMlate-glass-71191
12/19/2025, 9:13 AMtall-airline-16756
12/19/2025, 9:13 AMlate-glass-71191
12/19/2025, 9:18 AMSIP trunk created: ${trunk.trunkId});
console.log('Creating LiveKit dispatch rule');
const dispatchRule = await livekitService.ensureDispatchRule(
agent.id,
trunk.trunkId,
phoneNumber,
'call-'
);tall-airline-16756
12/19/2025, 9:18 AMconst dispatch = await this.sipClient.createSipDispatchRule(
{
type: "individual",
roomPrefix: roomPrefix,
},
{
name: ruleName,
trunkIds: [trunkId],
hidePhoneNumber: false,
metadata: JSON.stringify({ agentId, phoneNumber, createdAt: new Date().toISOString() }),
attributes: { agent_id: agentId, phone_number: phoneNumber },
roomConfig: {
agents: [
{
agentName: agent.name, // must match the agent identity your agent registers with
metadata: JSON.stringify({ agentId }),
},
],
},
}
);
Notes and checklist
• Agent process must be deployed and register with LiveKit using the same agentName.
• Make agentName unique per agent (recommended) to avoid ambiguous dispatching.
• If you use a queue model (multiple agents per rule), include multiple agents in roomConfig.agents.
• After creating the rule, verify in the LiveKit dashboard (Telephony → Configuration) that the Agents field is populated.
If you want, paste:
• the value your agent uses when registering (agentName), and
• the SDK version you’re using;
I’ll verify the exact field names for your SDK version and adapt the snippet if needed.late-glass-71191
12/19/2025, 9:40 AMtall-airline-16756
12/19/2025, 9:41 AMroomConfig expects a full RoomConfiguration object, where agents is an array of agent objects, not raw values. Provide required room fields (you can use sensible defaults) and make each agent an object with agentName and optional metadata.
Fixed snippet:
const dispatch = await this.sipClient.createSipDispatchRule(
// Rule definition
{
type: "individual",
roomPrefix,
pin: "",
},
// Dispatch options
{
name: ruleName,
trunkIds: [trunkId],
hidePhoneNumber: false,
metadata: JSON.stringify({
agentId,
phoneNumber,
createdAt: new Date().toISOString(),
}),
attributes: {
agent_id: agentId,
phone_number: phoneNumber,
},
roomConfig: {
name: `roomcfg-${agentId}`, // required name for the room config
emptyTimeout: 60, // seconds before empty room is cleaned
departureTimeout: 15, // seconds to wait on participant departure
maxParticipants: 2, // caller + agent
enableSip: true,
agents: [
{
agentName: agentName, // must match the agent identity registered with LiveKit
metadata: JSON.stringify({ agentId }),
},
],
},
}
);
Adjust the timeouts/maxParticipants to suit your use case. If your SDK version requires different field names, tell me the livekit-server-sdk version and I’ll adapt.late-glass-71191
12/19/2025, 9:48 AMtall-airline-16756
12/19/2025, 9:49 AMRoomAgentDispatch. Construct the object with the SDK's message helper (usually fromPartial) or the SDK-provided constructor shape.
Try this (adjust import path if your SDK version differs):
import { RoomAgentDispatch } from "livekit-server-sdk/dist/proto/livekit_sip"; // path may vary
// or use the exported helper if available:
import { RoomAgentDispatch as RAD } from "livekit-server-sdk";
// build agent dispatch using fromPartial if available
const agentObj = (RAD as any).fromPartial
? (RAD as any).fromPartial({ agentName, metadata: JSON.stringify({ agentId }) })
: { agentName, metadata: JSON.stringify({ agentId }) };
// full roomConfig
const roomConfig = {
name: `roomcfg-${agentId}`,
emptyTimeout: 60,
departureTimeout: 15,
maxParticipants: 2,
enableSip: true,
agents: [agentObj],
};
// create rule
const dispatch = await this.sipClient.createSipDispatchRule(
{ type: "individual", roomPrefix, pin: "" },
{
name: ruleName,
trunkIds: [trunkId],
hidePhoneNumber: false,
metadata: JSON.stringify({ agentId, phoneNumber, createdAt: new Date().toISOString() }),
attributes: { agent_id: agentId, phone_number: phoneNumber },
roomConfig,
}
);
If fromPartial isn’t available in your installed SDK build, import the proto helpers from the actual generated file path (check node_modules/livekit-server-sdk/dist/proto or similar) and use its fromPartial() to create the agent object.
If you prefer a quick workaround, coerce the plain object to any:
agents: [{ agentName, metadata: JSON.stringify({ agentId }) } as any]
This works but is less type-safe.
Tell me your livekit-server-sdk version and I’ll give the exact import and fromPartial usage for that version.late-glass-71191
12/19/2025, 9:53 AMtall-airline-16756
12/19/2025, 9:53 AMfromPartial) instead of plain POJOs. Below is a concise, working TypeScript snippet that builds a RoomAgentDispatch and RoomConfiguration correctly and calls createSipDispatchRule.
Notes:
• Import the proto types from the SDK's proto export.
• Use fromPartial to create protobuf instances.
• Keep agentName the exact identity your agent registers with.
Code:
import { SipClient, proto } from "livekit-server-sdk";
const sipClient = new SipClient(process.env.LIVEKIT_URL!, process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!);
async function createDispatchForAgent({
ruleName,
trunkId,
phoneNumber,
roomPrefix,
agentId,
agentName,
}: {
ruleName: string;
trunkId: string;
phoneNumber: string;
roomPrefix: string;
agentId: string;
agentName: string;
}) {
// build RoomAgentDispatch via fromPartial
const agentDispatch = proto.RoomAgentDispatch.fromPartial({
agentName,
metadata: JSON.stringify({ agentId }),
});
// build RoomConfiguration via fromPartial with required fields
const roomConfig = proto.RoomConfiguration.fromPartial({
name: `roomcfg-${agentId}`,
emptyTimeout: 60,
departureTimeout: 15,
maxParticipants: 2,
enableSip: true,
agents: [agentDispatch],
});
const dispatch = await sipClient.createSipDispatchRule(
{
type: "individual",
roomPrefix,
pin: "",
},
{
name: ruleName,
trunkIds: [trunkId],
hidePhoneNumber: false,
metadata: JSON.stringify({ agentId, phoneNumber, createdAt: new Date().toISOString() }),
attributes: { agent_id: agentId, phone_number: phoneNumber },
roomConfig,
}
);
return dispatch;
}
If you prefer a quick workaround while iterating, you can cast to any:
agents: [{ agentName, metadata: JSON.stringify({ agentId }) } as any]
—but using fromPartial keeps types correct.
Want me to adapt the snippet to also create the trunk then the dispatch in one function (with rollback on failure)?late-glass-71191
12/19/2025, 10:23 AMtall-airline-16756
12/19/2025, 10:24 AMlate-glass-71191
12/19/2025, 10:26 AMtall-airline-16756
12/19/2025, 10:26 AMlate-glass-71191
12/19/2025, 2:20 PMtall-airline-16756
12/19/2025, 2:21 PMlate-glass-71191
12/19/2025, 4:57 PMtall-airline-16756
12/19/2025, 4:57 PMmetrics_collected events from STT/TTS/LLM components in @livekit/agents.
3) Programmatic examples (Node/TS)
• Analytics API (fetch totals):
// generate token server-side (AccessToken from livekit-server-sdk)
const res = await fetch(`<https://cloud-api.livekit.io/api/project/${PROJECT_ID}/sessions?limit=10>`, {
headers: { Authorization: `Bearer ${TOKEN}` }
});
const data = await res.json();
// data.sessions[i].bandwidthIn, data.sessions[i].bandwidthOut
• Agent pipeline metrics (agents SDK):
// in your Agent code
this.session.stt.on('metrics_collected', (m) => console.log('STT', m));
this.session.tts.on('metrics_collected', (m) => console.log('TTS', m));
4) Best practices
• Use Analytics API for aggregated totals and post-call reports (bandwidthIn/out).
• Use in-process/event metrics for real‑time alerts and fine‑grained telemetry (packet loss, RTT).
• Correlate by roomId/sessionId/agentId and store trunk/dispatch IDs for traceability.
• Push metrics to your chosen backend (Prometheus/Datadog/Elastic) for dashboards and alerts.
If you want, I can:
• Provide a full Node/TS snippet that (a) generates the analytics token and (b) fetches session list + normalizes bandwidth metrics, or
• Show how to instrument and export real‑time track stats to Prometheus. Which would you like? Sources: Analytics API | LiveKit docs | TTS Metrics | LiveKit docs | LLM Metrics | LiveKit docs | STT Metrics | LiveKit docslate-glass-71191
12/19/2025, 5:02 PMtall-airline-16756
12/19/2025, 5:02 PMimport fetch from "node-fetch";
import { AccessToken } from "livekit-server-sdk";
import dotenv from "dotenv";
dotenv.config();
const LIVEKIT_API_HOST = "<https://cloud-api.livekit.io>";
const PROJECT_ID = process.env.PROJECT_ID!;
const API_KEY = process.env.LIVEKIT_API_KEY!;
const API_SECRET = process.env.LIVEKIT_API_SECRET!;
if (!PROJECT_ID || !API_KEY || !API_SECRET) {
throw new Error("Set PROJECT_ID, LIVEKIT_API_KEY, LIVEKIT_API_SECRET in .env");
}
function makeAnalyticsToken(ttlSeconds = 60 * 10) {
const at = new AccessToken(API_KEY, API_SECRET, { identity: "analytics", ttl: ttlSeconds });
// analytics scope is required for analytics API access
// grant shape: { analytics: true } — AccessToken.addGrant accepts any object
(at as any).addGrant({ analytics: true });
return at.toJwt();
}
type Session = {
sessionId: string;
room: string;
createdAt: string;
endedAt?: string | null;
bandwidthIn?: number; // bytes
bandwidthOut?: number; // bytes
publishBps?: number; // average publish bits/sec (may be time-series)
participants?: any[];
};
async function fetchSessions(startISO: string, endISO: string, limit = 100): Promise<Session[]> {
const token = makeAnalyticsToken();
const url = `${LIVEKIT_API_HOST}/api/project/${PROJECT_ID}/sessions?start=${encodeURIComponent(startISO)}&end=${encodeURIComponent(endISO)}&limit=${limit}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Analytics API failed: ${res.status} ${await res.text()}`);
const json = await res.json();
// API returns { sessions: [...] }
return json.sessions || [];
}
function topN<T>(arr: T[], keyFn: (x: T) => number, n = 3) {
return arr
.map((v) => ({ v, k: keyFn(v) }))
.sort((a, b) => b.k - a.k)
.slice(0, n)
.map((x) => x.v);
}
async function main() {
// time window: last 24 hours
const end = new Date();
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
const sessions = await fetchSessions(start.toISOString(), end.toISOString(), 500);
// normalize fields
const normalized = sessions.map((s: any) => ({
sessionId: s.sessionId || s.id || s.room,
room: s.room || s.roomName,
bandwidthIn: Number(s.bandwidthIn || s.bandwidth_in || 0),
bandwidthOut: Number(s.bandwidthOut || s.bandwidth_out || 0),
publishBps: Number(s.publishBps || s.publish_bps || 0),
raw: s,
}));
// top by total upstream (bandwidthIn bytes)
const topUp = topN(normalized, (x) => x.bandwidthIn, 3);
// top by total downstream (bandwidthOut bytes)
const topDown = topN(normalized, (x) => x.bandwidthOut, 3);
// top by average publish bitrate (publishBps)
const topPublish = topN(normalized, (x) => x.publishBps, 3);
console.log("Top 3 by upstream (bandwidthIn bytes):");
topUp.forEach((s, i) => console.log(`${i + 1}. session=${s.sessionId} room=${s.room} bytes=${s.bandwidthIn}`));
console.log("\nTop 3 by downstream (bandwidthOut bytes):");
topDown.forEach((s, i) => console.log(`${i + 1}. session=${s.sessionId} room=${s.room} bytes=${s.bandwidthOut}`));
console.log("\nTop 3 by average publishBps:");
topPublish.forEach((s, i) => console.log(`${i + 1}. session=${s.sessionId} room=${s.room} bps=${s.publishBps}`));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Notes:
• Fields names can vary by API version; the code normalizes common variants.
• `bandwidthIn`/`bandwidthOut` are bytes totals for the session; publishBps may be an average or a timeseries depending on API response — adjust aggregation if you want sum/avg across time buckets.
• Increase limit or page through results for larger datasets.
• For production, push results to metrics backend (Prometheus/Datadog) instead of console.
Want a version that:
• Aggregates per-agent across sessions, or
• Uses sliding windows and averages over time-series publish data?
Which one should I show next?late-glass-71191
12/19/2025, 5:05 PMtall-airline-16756
12/19/2025, 5:05 PMlate-glass-71191
12/19/2025, 5:06 PMtall-airline-16756
12/19/2025, 5:06 PMimport fetch from "node-fetch";
import { AccessToken } from "livekit-server-sdk";
import dotenv from "dotenv";
dotenv.config();
const API_HOST = "<https://cloud-api.livekit.io>";
const PROJECT_ID = process.env.PROJECT_ID!;
const API_KEY = process.env.LIVEKIT_API_KEY!;
const API_SECRET = process.env.LIVEKIT_API_SECRET!;
if (!PROJECT_ID || !API_KEY || !API_SECRET) throw new Error("Set PROJECT_ID, LIVEKIT_API_KEY, LIVEKIT_API_SECRET");
function makeAnalyticsToken(ttlSeconds = 60 * 10) {
const at = new AccessToken(API_KEY, API_SECRET, { identity: "analytics", ttl: ttlSeconds });
(at as any).addGrant({ analytics: true });
return at.toJwt();
}
type RawSession = any;
type Norm = {
sessionId: string;
room: string;
bandwidthTotal: number; // bytes (in+out)
publishBps: number; // avg bits/sec
packetLossPct: number; // percentage [0..100]
durationSec: number;
raw: RawSession;
};
// helper stats
function mean(arr: number[]) { return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : 0; }
function p95(arr: number[]) {
if (!arr.length) return 0;
const s = arr.slice().sort((a,b)=>a-b);
const idx = Math.ceil(0.95 * s.length) - 1;
return s[Math.max(0, idx)];
}
async function fetchSessions(startISO: string, endISO: string, limit = 200): Promise<RawSession[]> {
const token = makeAnalyticsToken();
const url = `${API_HOST}/api/project/${PROJECT_ID}/sessions?start=${encodeURIComponent(startISO)}&end=${encodeURIComponent(endISO)}&limit=${limit}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Analytics API failed: ${res.status} ${await res.text()}`);
const json = await res.json();
return json.sessions || [];
}
function normalizeSession(s: RawSession): Norm {
// field names may vary; normalize common variants
const bandwidthIn = Number(s.bandwidthIn ?? s.bandwidth_in ?? s.upstreamBytes ?? 0);
const bandwidthOut = Number(s.bandwidthOut ?? s.bandwidth_out ?? s.downstreamBytes ?? 0);
const publishBps = Number(s.publishBps ?? s.publish_bps ?? s.avgPublishBps ?? 0);
// packet loss: try participant-level aggregate if available, otherwise 0
const participants = s.participants ?? s.participantsList ?? [];
// calculate weighted avg packet loss across participants if present
let packetLossPct = 0;
if (participants.length) {
const losses: number[] = participants.map((p: any) => Number(p.packetLossPct ?? p.packet_loss_pct ?? p.packet_loss || 0));
packetLossPct = mean(losses);
} else {
packetLossPct = Number(s.packetLossPct ?? s.packet_loss_pct ?? 0);
}
const durationSec = Number(s.connectionMinutes ? s.connectionMinutes * 60 : (s.durationSec ?? s.duration_seconds ?? 0));
return {
sessionId: s.sessionId ?? s.id ?? s.room ?? "unknown",
room: s.room ?? s.roomName ?? s.room_name ?? "unknown",
bandwidthTotal: bandwidthIn + bandwidthOut,
publishBps,
packetLossPct,
durationSec,
raw: s,
};
}
function topNBy<T>(arr: T[], key: (x:T)=>number, n=3) {
return arr.slice().sort((a,b)=> key(b)-key(a)).slice(0,n);
}
async function aggregateWindow(hours = 24) {
const end = new Date();
const start = new Date(end.getTime() - hours * 3600 * 1000);
const raw = await fetchSessions(start.toISOString(), end.toISOString(), 1000);
const norm = raw.map(normalizeSession);
// metric arrays
const bandwidthTotals = norm.map(n => n.bandwidthTotal); // bytes
const publishArr = norm.map(n => n.publishBps); // bps
const lossArr = norm.map(n => n.packetLossPct); // %
// compute stats
const stats = {
bandwidth: {
meanBytes: mean(bandwidthTotals),
p95Bytes: p95(bandwidthTotals),
totalBytes: bandwidthTotals.reduce((a,b)=>a+b,0),
topSessions: topNBy(norm, n=>n.bandwidthTotal, 3),
},
publishBps: {
meanBps: mean(publishArr),
p95Bps: p95(publishArr),
topSessions: topNBy(norm, n=>n.publishBps, 3),
},
packetLossPct: {
meanPct: mean(lossArr),
p95Pct: p95(lossArr),
topSessions: topNBy(norm, n=>n.packetLossPct, 3),
},
window: { start: start.toISOString(), end: end.toISOString(), sessionsCount: norm.length },
};
return stats;
}
async function main() {
try {
const stats = await aggregateWindow(24); // last 24 hours
console.log("Window:", stats.window);
console.log("\nBandwidth (bytes) — mean / p95 / total:");
console.log(stats.bandwidth.meanBytes, stats.bandwidth.p95Bytes, stats.bandwidth.totalBytes);
console.log("Top sessions by bandwidth:");
stats.bandwidth.topSessions.forEach((s,i)=> console.log(`${i+1}. ${s.sessionId} room=${s.room} bytes=${s.bandwidthTotal}`));
console.log("\nPublish bitrate (bps) — mean / p95:");
console.log(stats.publishBps.meanBps, stats.publishBps.p95Bps);
stats.publishBps.topSessions.forEach((s,i)=> console.log(`${i+1}. ${s.sessionId} room=${s.room} bps=${s.publishBps}`));
console.log("\nPacket loss (%) — mean / p95:");
console.log(stats.packetLossPct.meanPct, stats.packetLossPct.p95Pct);
stats.packetLossPct.topSessions.forEach((s,i)=> console.log(`${i+1}. ${s.sessionId} room=${s.room} loss=${s.packetLossPct}`));
} catch (err) {
console.error("error:", err);
process.exit(1);
}
}
main();
Notes
• Field names returned by the Analytics API can vary; normalization in normalizeSession handles common variants.
• bandwidth totals are in bytes; publishBps is bits/sec. Convert units for display as needed.
• Packet loss is averaged across participants in a session if participant data exists.
• For production: page results, handle pagination, and push aggregates to your metrics backend or cache them for dashboard queries.
If you want, I can:
• Change the time window (last 1h or 7d),
• Return per-agent aggregates (group by agentId using session.raw attributes),
• Provide Prometheus export example for these aggregates. Which next?late-glass-71191
12/19/2025, 9:13 PMtall-airline-16756
12/19/2025, 9:13 PMimport { AccessToken } from "livekit-server-sdk";
const at = new AccessToken(API_KEY, API_SECRET, { identity: "analytics", ttl: 600 });
(at as any).addGrant({ analytics: true });
const token = at.toJwt();
• Confirm token looks like: header.payload.signature (contains two dots).
2) Postman header — put the JWT raw in Bearer header
• Header: Authorization: Bearer <token>
• Do NOT base64-encode the token or prefix it (your earlier code used Basic with base64(key:secret) or created a Bearer with a base64 string — that’s wrong).
3) Use the correct endpoint
• Analytics host: https://cloud-api.livekit.io
• Example URL: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions?start=...&end=...
4) Quick verification steps
• Print the token server-side and paste into jwt.io to validate format/claims.
• From the server environment, run curl to confirm:
curl -H "Authorization: Bearer <TOKEN>" "https://cloud-api.livekit.io/api/project/$PROJECT_ID/sessions?limit=1"
If you want, paste the token (redact middle characters) or the snippet you used to generate it and I’ll point out what’s wrong.late-glass-71191
12/19/2025, 9:16 PMtall-airline-16756
12/19/2025, 9:16 PMimport { AccessToken } from "livekit-server-sdk";
function makeAnalyticsToken(ttlSeconds = 600) {
const at = new AccessToken(process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!, {
identity: "analytics",
ttl: ttlSeconds,
});
(at as any).addGrant({ analytics: true });
return at.toJwt();
}
• Verify the token has two dots (header.payload.signature). Paste into jwt.io to confirm.
2) Use the token raw in the Bearer header (no base64/Basic encoding)
• Header: Authorization: Bearer <JWT>
3) Ensure you call the correct host/path
• URL must be: https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions?...
(not cloud.livekit.io or the dashboard URL)
4) Quick checks to run
• Console.log the token and confirm format.
• From the server environment run:
curl -H "Authorization: Bearer <TOKEN>" "https://cloud-api.livekit.io/api/project/$PROJECT_ID/sessions?limit=1"
• If curl returns 401, verify API key/secret and that token TTL isn’t expired.
If you want, share (redact secrets):
• The exact token string (mask middle chars) or the function you use to generate it, and
• The fetchSessions request code (headers and URL)
I’ll point out the exact line causing the invalid token and provide the minimal fix.late-glass-71191
12/19/2025, 9:25 PMtall-airline-16756
12/19/2025, 9:25 PMvideo in the payload:
Payload (decoded) { "video": { "analytics": true }, "iss": "APIumy... ", "exp": ..., "nbf": 0, "sub": "analytics" }
The Analytics API expects the analytics grant at the top level (not nested). Generate the token like this so the grant is correct:
import { AccessToken } from "livekit-server-sdk";
function makeAnalyticsToken(ttlSeconds = 600) {
const at = new AccessToken(process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!, {
identity: "analytics",
ttl: ttlSeconds,
});
// add analytics grant at top level
(at as any).addGrant({ analytics: true });
return at.toJwt();
}
Verify:
• The token payload contains "analytics": true at the top level (not under "video").
• Token looks like header.payload.signature (two dots).
• Use header: Authorization: Bearer <token>
• Call: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions?start=...&end=...
If you want, paste a redacted token (first/last 6 chars visible) after regenerating and I’ll confirm the payload shape.