for an agent created from the frontend, i want to ...
# ask-ai
l
for an agent created from the frontend, i want to able to prpgramtically set the twilio phone number associated with the agent . i have been manually doing sip trunking in livekit cloud until now
t
Thanks for reaching out, Anirban. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I’m responding to your request about programmatically assigning a Twilio phone number to an agent created from the frontend. Short answer: you cannot directly assign a Twilio phone number to an agent from the frontend at agent-creation time. Phone numbers are tied to SIP trunks and dispatch rules, not individual agent instances. Incoming calls to a number are routed by a trunk + dispatch rule into a room, and the agent is dispatched into that room. What you CAN do (recommended approach) • Implement a small backend service that the frontend calls when you want to “assign” a number to an agent. • Backend workflow: a. Verify the Twilio number is provisioned on your Twilio trunk (via Twilio API if needed). b. Create or update a LiveKit inbound trunk / phone-number mapping so that calls to that number route into the desired room or dispatch flow. c. Create/update a dispatch rule that routes calls for that number to the agent’s room or queue. d. Return status to the frontend so the agent UI can reflect the assigned number. Relevant LiveKit capabilities to use • SIP trunk and inbound trunk configuration (create/update programmatically via LiveKit APIs). • Dispatch rules to map incoming numbers to rooms/agents. • Phone Numbers API to manage numbers programmatically if you need dynamic number assignment. If you’d like, tell me: • Whether you already have a backend that can call LiveKit/Twilio APIs, or if you need example endpoints. • Whether you want numbers routed to a specific room per-agent or to a queue that an agent then picks from. I can then provide a concise example workflow or sample API sequence to implement this. Sources: Create and configure a Twilio SIP trunk | LiveKit docs | SIP trunk setup | LiveKit docs | Agents telephony integration | LiveKit docs | SIP APIs | LiveKit docs | Phone Numbers APIs | LiveKit docs
l
provide sample code for backend workflow in node JS
t
Thanks — I’ll provide Node.js sample backend code to implement the workflow that programmatically assigns a Twilio number to an agent by managing SIP trunks and dispatch rules in LiveKit. Before I share the code: do you want plain JavaScript or TypeScript? Also confirm whether you already have: • A backend able to call LiveKit server APIs (API key + secret) • Twilio credentials (Account SID + Auth Token) and a Twilio SIP Trunk configured If you confirm, I’ll return a concise, ready-to-run example (environment variables, minimal endpoints: assign-number and create-call) in your preferred language. Sources: Create and configure a Twilio SIP trunk | LiveKit docs | Making outbound calls | LiveKit docs | Agent dispatch | LiveKit docs
l
i want it in ts yes yes
t
Thanks — below is a concise TypeScript example backend that demonstrates the workflow: • Verify a Twilio number (optional) via Twilio API • Create/update LiveKit inbound SIP trunk for that number • Create/update a LiveKit dispatch rule mapping the number to an agent (routes incoming calls into a room) • Create a room and generate an agent token so the agent can join Files: 1) .env (required env vars) 2) src/index.ts (Express endpoints) 3) src/livekit.ts (LiveKit helper functions) 4) src/twilio.ts (Twilio helper) .env
Copy code
PORT=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:
Copy code
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
Copy code
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
Copy code
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
Copy code
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?
l
process.env.LIVEKIT_HOST, process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET how do theses env variable look lke
t
Great — here’s how those LiveKit env vars typically look and where to get them. • LIVEKIT_HOST (or LIVEKIT_API_URL) ◦ Example: https://livekit.example.com or https://cloud.livekit.com ◦ What it is: the base URL of your LiveKit server’s admin REST API (include https:// and no trailing /admin). ◦ Where to get it: your LiveKit Cloud dashboard or your self-hosted LiveKit instance URL. • LIVEKIT_API_KEY ◦ Example: lk_ABC123def456GHI ◦ What it is: the API key (public identifier) used for admin/auth requests. ◦ Where to get it: generated in the LiveKit dashboard (or from your self-hosted config). • LIVEKIT_API_SECRET ◦ Example: s3cr3tLongBase64String== ◦ What it is: the secret paired with the API key — used to sign requests and create server tokens. ◦ Where to get it: shown when you create the API key in the LiveKit dashboard (store securely). Notes • Use Basic auth for admin REST calls: Authorization: Basic base64(LIVEKIT_API_KEY:LIVEKIT_API_SECRET). • Keep API secret out of client/front-end code; store in server environment or secret manager. • If using LiveKit Cloud, the host is the cloud URL shown in your account; if self-hosted, use your instance’s public URL.
l
for this piece of code const trunk = await livekitService.ensureInboundTrunk( agent.id, phoneNumber ); i am getting the following errors Failed to create inbound trunk: Error: LiveKit POST /sip/inbound failed: 404 <!DOCTYPE html><html lang="en" data-lk-theme="dark" class="antialiased public_sans_b07ce9e6-module__uPX-Eq__variable monofont_5c5fc04-module___eJXaq__variable displayfont_8751b474-module__wMLsoG__variable"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/chunks/c4fbc61ea51b60d0.css?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" data-precedence="next"/><link rel="stylesheet" href="/_next/static/chunks/4b475809ef2fda2b.css?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" data-precedence="next"/><link rel="stylesheet" href="/_next/static/chunks/a79f94cbb864d096.css?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" data-precedence="next"/><link rel="stylesheet" href="/_next/static/chunks/da8cd7070da95b6c.css?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" href="/_next/static/chunks/735217f8232df1fc.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5"/><script src="/_next/static/chunks/a1a55df4e977f376.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/3fceee85cee9d32e.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/c8f3e287845b396b.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/60562779162df199.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/4043f5b69f295eb8.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/turbopack-53609a8a7b3684a0.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/482b3758c20f01f9.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/00251d4408e22bee.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/edd0d8db99042f58.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/43e97c0af0c8e0b6.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/fe1e78351dccfe28.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/fd2e8553a5e5c2ef.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/51a3f1a191421279.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/d25e48f107f10bd0.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/b5d302181bc4a9a4.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/11e4d546ba8e2ee7.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/433f8f26ab145411.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/d2d92e60f43e15d0.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/9da58dbcf5aafa70.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/476dccdb56bfdd1d.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/5d22fc6509ea69cb.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/673e25591682b3fd.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/87cb80d6e62c6d71.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><script src="/_next/static/chunks/55ff715c0ee7a020.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" async="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script><link rel="preload" href="/_next/static/chunks/c5040b2c926f80d2.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" as="script" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" fetchPriority="low"/><link rel="preload" href="/_next/static/chunks/efe45fe56a9a0a39.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" as="script" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" fetchPriority="low"/><meta name="robots" content="noindex"/><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#070707"/><meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/><meta name="next-size-adjust" content=""/><title>LiveKit Cloud</title><meta name="description" content="Build applications that can see, hear, and speak with LiveKit Cloud, an end-to-end developer platform for voice, video, and physical AI."/><link rel="author" href="https://livekit.io"/><meta name="author" content="LiveKit"/><meta name="creator" content="LiveKit"/><meta property="og:title" content="LiveKit Cloud"/><meta property="og:description" content="Build applications that can see, hear, and speak with LiveKit Cloud, an end-to-end developer platform for voice, video, and physical AI."/><meta property="og:url" content="https://cloud.livekit.io"/><meta property="og:site_name" content="LiveKit Cloud"/><meta property="og:locale" content="en_US"/><meta property="og:type" content="website"/><meta name="twitter:card" content="summary"/><meta name="twitter:title" content="LiveKit Cloud"/><meta name="twitter:description" content="Build applications that can see, hear, and speak with LiveKit Cloud, an end-to-end developer platform for voice, video, and physical AI."/><link rel="icon" href="/favicon.ico?favicon.27689d03.ico" sizes="48x48" type="image/x-icon"/><link rel="apple-touch-icon" href="/apple-icon?e5390ecad85ba72a" type="image/png" sizes="180x180"/><script src="/_next/static/chunks/a6dad97d9634a72d.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" noModule="" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369"></script></head><body class="bg-bg1 font-sans"><div hidden=""><!--$--><!--/$--></div><!--$--><!--/$--><a href="#main-content" class="absolute left-0 top-0 z-[9999] m-2 block -translate-y-[150%] rounded border border-separator2 bg-bg1 p-5 text-fgAccent1 underline underline-offset-4 focus:translate-y-0">Skip to main content</a><script nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369">((a,b,c,d,e,f,g,h)=>{let i=document.documentElement,j=["light","dark"];function k(b){var c;(Array.isArray(a)?a:[a]).forEach(a=>{let c="class"===a,d=c&&f?e.map(a=>f[a]||a):e;c?(i.classList.remove(...d),i.classList.add(f&&f[b]?f[b]:b)):i.setAttribute(a,b)}),c=b,h&&j.includes(c)&&(i.style.colorScheme=c)}if(d)k(d);else try{let a=localStorage.getItem(b)||c,d=g&&"system"===a?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":a;k(d)}catch(a){}})(["class","data-lk-theme"],"theme","dark",null,["light","dark"],null,true,true)</script><!--$--><!--/$--><div class="min-h-screen bg-bg1 text-fg1"><div class="flex min-h-screen items-center justify-center" data-testid="404"><div class="flex max-w-md flex-col items-center gap-8"><div class="flex flex-col items-center gap-6"><img alt="LiveKit logo" loading="lazy" width="110" height="37" decoding="async" data-nimg="1" class="invert dark:invert-0" style="color:transparent" src="/images/logo.svg"/><div class="h-px w-56 bg-separator1"></div></div><div class="flex flex-col items-center gap-3"><div class="text-center text-2xl">404</div><div class="text-center text-fg1">This resource could not be found. <br/> For more information,<!-- --> <a target="_blank" rel="noopener noreferrer" class="text-fgAccent1 underline" href="https://livekit.statuspage.io/">check our status page</a>.</div></div></div></div><!--$--><!--/$--><section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false"></section></div><script src="/_next/static/chunks/735217f8232df1fc.js?dpl=dpl_7xqXYs4GU98kBf7nPqHEShimSiU5" nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369" id="_R_" async=""></script><script nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369">(self.__next_f=self.__next_f||[]).push([0])</script><script nonce="b1e1345e-2e4f-4c1b-b135-539bdb690369">self.__next_f.push
t
Thanks — that 404 means your request hit the LiveKit web dashboard (HTML) instead of the server admin API. Quick checklist and fixes to get your
ensureInboundTrunk
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:
Copy code
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 docs
✅ 1
l
kindly show sample code using the SDK for node js
t
Thanks — below is a concise, complete TypeScript example using the LiveKit Server SDK that shows how to list/create an inbound SIP trunk and create a dispatch rule. This uses
wss://<project>.livekit.cloud
(or your self‑hosted server URL) for
LIVEKIT_URL
and runs on the backend. Install:
Copy code
npm install livekit-server-sdk dotenv
npm install -D typescript @types/node
.env (example)
Copy code
LIVEKIT_URL=<wss://your-project.livekit.cloud>
LIVEKIT_API_KEY=lk_ABC123...
LIVEKIT_API_SECRET=your_secret_here
src/sip.ts
Copy code
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)
Copy code
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 docs
l
private async lkRequest(path: string, method = 'GET', body?: any) { const url = `${LIVEKIT_API_URL}${path}`; const auth = Buffer.from(
${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 here
t
Thanks — I see two issues in your lkRequest code that cause the 401 and the earlier 404. Quick diagnosis • You’re using Authorization: Bearer <base64(key:secret)> — LiveKit admin endpoints expect Basic auth (base64 of key:secret) when using HTTP, not a Bearer header. • Your base URL likely points at the dashboard (HTML) or the wrong scheme. For SIP admin operations use your LiveKit server URL (the project endpoint) and preferably the Server SDK so Twirp endpoints are handled correctly. What to change 1) Use Basic auth header:
Copy code
const 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.
l
give me sample postman request for livekit so that i can setup ibound trunk
t
Thanks — here’s a ready Postman example you can paste into Postman (or run as curl) to create an inbound SIP trunk in LiveKit. This uses the Twirp RPC endpoint and requires a LiveKit admin token with the
sip_admin
grant. 1) Generate a SIP admin token (server-side) — example TypeScript:
Copy code
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):
Copy code
{
  "name": "My Inbound Trunk",
  "numbers": [
    "+15105550100"
  ],
  "krispEnabled": true,
  "metadata": "Trunk for support line",
  "authUsername": "sipuser",
  "authPassword": "sippass"
}
3) curl equivalent
Copy code
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 docs
l
const dispatch = await this.sipClient.createSipDispatchRule( // Rule definition { dispatchRuleIndividual: { 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, }, } ); error -> Object literal may only specify known properties, and 'dispatchRuleIndividual' does not exist in type 'SipDispatchRuleDirect | SipDispatchRuleIndividual'.ts(2353) (property) dispatchRuleIndividual: { roomPrefix: string; pin: string; } what should be the correct option ?
t
Use the SDK’s typed rule shape — pass an object with
type: 'individual'
and
roomPrefix
(not
dispatchRuleIndividual
). Replace your first argument with the correct shape. Example:
Copy code
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:
Copy code
{ 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 | DeepWiki
l
why do we need to create a room for agent during agent creation, then store the dispatch and trunk ID in DB
t
Good question — here’s why creating a room for an agent at agent-creation time and persisting trunk/dispatch IDs is useful and recommended. • Call isolation and lifecycle ◦ Each incoming SIP call is represented as a LiveKit participant inside a room. Creating a room per agent (or a predictable agent-room naming scheme) gives you a stable target to route calls to and lets the agent join that room with a token when ready. • Deterministic dispatching ◦ Dispatch rules map incoming numbers on a trunk to rooms (or room prefixes). Storing the dispatch rule ID and trunk ID lets your backend know exactly which trunk+rule will route calls to that agent’s room, making routing deterministic and auditable. • Fast, reliable agent assignment ◦ With a room already created and known to your system, dispatch can put callers into a room immediately and your agent can be programmatically joined or signaled to join. This avoids on-the-fly room creation delays during an inbound call. • State, retries and recovery ◦ Persisted trunk/dispatch IDs let you reconcile state after errors, redeploys, or rollbacks. If a trunk or rule changes, you can update DB records and reconfigure dispatch without guessing which agent was affected. • Instrumentation, billing and debugging ◦ Storing the trunk and dispatch IDs links calls to specific infrastructure for metrics, cost attribution, and troubleshooting (which trunk handled this call, which rule routed it, when it was created, etc.). • Flexibility for routing models ◦ Storing these IDs lets you support multiple routing models: one-room-per-agent, queue-based routing (agents pick from a queue room), or hybrid approaches. The DB record becomes the single source of truth for how that agent is reachable. When you might skip creating a room up-front • If you use a pure queue model where callers land in a shared queue room and agents pull calls from the queue, you may not need a dedicated room per agent until they accept a call. Even then, persisting trunk/dispatch IDs remains helpful. Suggested minimal data to persist • agentId, agentRoomName (or room prefix pattern) • trunkId (inbound trunk) • dispatchRuleId • phoneNumber(s) assigned • metadata/timestamps If you want, I can: • Suggest a DB schema example and room-naming convention, or • Show code for creating the room + dispatch + storing IDs in a transaction so creation and DB persist are atomic. Which would help more? Sources: Agent dispatch | LiveKit docs | SIP overview | LiveKit docs | SIP dispatch rule | LiveKit docs
l
since i am assigning a single phone number per agent do you think room creation is necesary as i believe the dispatch rule is smart enough to handle calls. COmment
t
Short answer: no — you don’t strictly need to pre-create a dedicated room per agent if you assign one phone number per agent. Dispatch rules can route incoming calls into dynamically-created rooms (using a room prefix), and the agent can be dispatched into that room when the call arrives. Why that works • Dispatch rule + trunk maps the called number to a room prefix (e.g., "call-"). LiveKit will put the SIP caller into a room matching that prefix (often a new room per call). • Your backend/agent logic can then join the generated room (or be signaled to join) so the agent and caller meet in the same session. Trade-offs to consider • Pros of dynamic rooms ◦ Simpler provisioning — no upfront room creation per agent. ◦ Clean per-call isolation (one room per call). • Cons of dynamic rooms ◦ You must ensure the agent is reliably notified to join the new room when a call arrives (signaling/webhook or agent dispatch). ◦ Harder to attach persistent agent-level metadata tied to a single static room (though you can store mappings in DB). ◦ Slightly more work on reconnection/forensics because room names vary per call. When to pre-create a room • You want a stable, always-available room identity for each agent (easy to reference in DB, monitoring, permissioning). • You need predictable room lifecycle or want to reuse room state across calls. • You prefer simpler agent-side join logic (agent always joins the same room and accepts calls routed there). Recommendation • If you already have reliable agent signaling/dispatch when calls arrive, use dynamic rooms (dispatch rule with room prefix). • If you want simpler mapping, easier audits, or reduce signaling complexity, create a persistent room per agent and store trunk/dispatch IDs. If you’d like, I can: • Show the exact dispatch rule + agent-join flow for the dynamic-room approach, or • Propose a minimal DB schema and transactional code for pre-creating rooms and storing trunk/dispatch IDs. Which do you prefer?
l
after the dispatch rule is crweated i see the Agents field is empty . how do i ensure the agent name is assigned in my route. Also should i keep agent name as a unique property try { console.log('Creating LiveKit SIP trunk'); const trunk = await livekitService.ensureInboundTrunk( agent.id, phoneNumber ); console.log(
SIP trunk created: ${trunk.trunkId}
); console.log('Creating LiveKit dispatch rule'); const dispatchRule = await livekitService.ensureDispatchRule( agent.id, trunk.trunkId, phoneNumber, 'call-' );
t
You need to include the agent in the dispatch rule’s roomConfig.agents when you create the rule. Also make the agent name unique so dispatch can reliably identify which agent to assign. What to change • Add a roomConfig with an agents array when calling createSipDispatchRule. • Use the exact agentName that your agent process registers with LiveKit. • Ensure agentName is unique (recommended) so one dispatch rule maps to one agent deterministically. Corrected TypeScript example
Copy code
const 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.
l
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: { agents: [ agentName: agentName, metadata: JSON.stringify({ agentId }) ] } } ); error -> Type '{ agents: any[]; }' is missing the following properties from type 'RoomConfiguration': name, emptyTimeout, departureTimeout, maxParticipants, and 14 more.ts(2740) (property) CreateSipDispatchRuleOptions.roomConfig?: RoomConfiguration fix it
t
You're close — the
roomConfig
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:
Copy code
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.
l
Type '{ agentName: string; metadata: string; }' is missing the following properties from type 'RoomAgentDispatch': equals, clone, fromBinary, fromJson, and 6 more.ts(2740) (property) RoomAgentDispatch.agentName: string @generated — from field: string agent_name = 1;
t
The TypeScript error means the SDK expects a protobuf-generated object (with protobuf methods) rather than a plain POJO for
RoomAgentDispatch
. 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):
Copy code
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:
Copy code
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.
l
"livekit-server-sdk": "^2.14.2",
t
Thanks — for v2.14.2 you should construct the protobuf objects with the generated helpers (e.g.
fromPartial
) 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:
Copy 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:
Copy code
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)?
l
Now how can i fetch monitoring details for teh agent like bandwidth etc ?
t
Good question — you can get agent monitoring (bandwidth, connection quality, per-participant metrics, agent pipeline latencies) in a few ways depending on Cloud vs self‑hosted and the level of detail you need. Options • Analytics API (Cloud) ◦ Use the Cloud Analytics endpoints to list sessions and get session details: ▪︎ GET /api/project/{projectId}/sessions?limit=... ▪︎ GET /api/project/{projectId}/sessions/{sessionId} ◦ Response includes bandwidthIn/bandwidthOut, publishBps, participants[] and quality/time-series metrics. ◦ Requires a Bearer token with analytics scope. • Agent Observability (Cloud dashboard) ◦ In the LiveKit Cloud UI open a session → Agent insights to see transcripts, traces, logs, and per-turn latencies for STT/TTS/LLM. ◦ Good for debugging agent pipeline performance. • In-app / real-time metrics (instrumentation) ◦ Capture participant stats and connection events in your agent or server code (publish/subscribe bps, packet loss, RTT). ◦ For SDK v2.x you can collect participant/track stats or subscribe to relevant events and forward metrics to your telemetry backend. • CLI & Dashboard ◦ Use the Cloud dashboard for realtime views or CLI commands (agent status / logs) for operational checks. Quick examples • Sessions list (curl): curl -H "Authorization: Bearer $TOKEN" "https://cloud-api.livekit.io/api/project/$PROJECT_ID/sessions?limit=10" • Session detail (curl): curl -H "Authorization: Bearer $TOKEN" "https://cloud-api.livekit.io/api/project/$PROJECT_ID/sessions/$SESSION_ID" What I need to help further • Are you on LiveKit Cloud or self‑hosted? • Do you want realtime per-call metrics (bandwidth, RTT) or agent-pipeline metrics (TTS/STT/LLM latencies)? • If Cloud, I can show exact request examples for your project and the token generation snippet. Which of those do you want next? Sources: Analytics API | LiveKit docs | https://blog.livekit.io/streamline-troubleshooting-with-agent-observability/#/portal| | https://blog.livekit.io/streamline-troubleshooting-with-agent-observability/#/portal/signup| | Streamline Troubleshooting with Agent Observability | TTS Metrics | LiveKit docs
l
currenty in clloud . will migrate later to self hosted
t
Great — since you’re on LiveKit Cloud now, here’s a concise plan and exact next steps to get agent monitoring (bandwidth, per‑participant stats, and agent pipeline metrics) that will also map cleanly when you migrate to self‑hosted. 1) Short-term (Cloud) — recommended • Use the Cloud Analytics API to fetch session & participant metrics: ◦ List sessions: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions?limit=10 • Session detail: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions/{SESSION_ID} • Response fields to read: bandwidthIn, bandwidthOut, publishBps, participants[], quality/time-series. • Generate a bearer token server-side for these calls (JWT with analytics scope). • Use these endpoints for dashboards, alerts, and periodic polling. 2) Real-time / per-call telemetry • In your agent or backend, collect participant stats from the SDK (publish/subscription bitrates, packet loss, RTT) and push into your monitoring system (Prometheus/Datadog): ◦ On participant connect / track publish events capture publishBps, packetsLost, rtt. ◦ Emit these metrics keyed by roomId, participantId (agentId). • For agent pipeline metrics (STT, TTS, LLM latencies) use Agent Observability (Cloud) or instrument your agent code to emit: ◦ STT latency, TTS latency, LLM TTFB, turn duration, VAD events. 3) Cloud Observability (recommended for agent internals) • Use the Agent Insights in the Cloud Dashboard for transcripts, traces, and per‑turn latencies (easy debugging). • Use the Analytics API for programmatic access to the same session-level metrics. 4) Example (curl) — list sessions • Replace TOKEN and PROJECT_ID: curl -H "Authorization: Bearer $TOKEN" "https://cloud-api.livekit.io/api/project/$PROJECT_ID/sessions?limit=10" 5) Example (curl) — session detail curl -H "Authorization: Bearer $TOKEN" "https://cloud-api.livekit.io/api/project/$PROJECT_ID/sessions/$SESSION_ID" 6) Notes for self-hosted migration • Self-hosted can expose the same telemetry if you enable and forward metrics/analytics (you’ll need to configure telemetry/observability and possibly run the analytics components). • Keep your instrumentation (agent-side metrics emission and usage of session IDs) unchanged so dashboards and alerts continue working post-migration. If you want, I can: • Show a small Node/TS snippet that: ◦ Generates the analytics token, ◦ Calls the sessions endpoint, ◦ Normalizes bandwidth/publishBps into a JSON payload for your metrics backend. • Or show exactly which participant fields to capture in real time from the SDK for Prometheus/Datadog. Which do you want?
l
Also is it posible to collect call logs after the call has ended
t
Yes — you can collect call logs after a call ends. Here are the practical ways to do it in LiveKit Cloud and what to capture: 1) Use Agent Observability (Cloud) • Agent Observability automatically stores session data after end: synchronized audio playback, transcripts, turn-by-turn traces (LLM/STT/TTS), and logs. • Access from Cloud Dashboard → select session → Agent insights. 2) Use Analytics API (programmatic) • Fetch completed sessions and details: ◦ List sessions: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions?limit=50&amp;start={ISO}&amp;end={ISO} • Session detail: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions/{SESSION_ID} • Response includes bandwidthIn/bandwidthOut, participants[], publishBps, connectionMinutes, quality/time-series. • Requires a Bearer token with analytics scope (generate server-side). 3) Webhooks (recommended for automated post-call processing) • Subscribe to room lifecycle events (e.g., room_finished) to be notified when a call ends. • On webhook, query the Analytics API and/or download observability artifacts (transcripts, logs). • Webhook payload example contains room id and timestamps — use those to fetch session details. 4) Agent / Server-side instrumentation • Emit and persist custom logs/metrics during the call (e.g., per-turn latency, STT/TTS logs, custom metadata). • On room_finished or via webhook, attach these records to the session in your DB for long-term storage. 5) Best practices • Use webhooks to avoid polling and ensure timely post-call processing. • Correlate records by roomId/sessionId and include agentId, trunkId, dispatchRuleId in logs. • Store or export transcripts/audio if you need retention beyond Cloud defaults (observability retention is limited). • For migration to self-hosted later, keep instrumentation and correlation fields unchanged so your processing continues to work. If you want, I can: • Provide a small Node.js example that (a) generates an analytics token, (b) receives room_finished webhooks, and (c) fetches session details to persist logs. Which would you prefer? Sources: Streamline Troubleshooting with Agent Observability | https://blog.livekit.io/streamline-troubleshooting-with-agent-observability/#/portal| | Data Processing Addendum | LiveKit | Server-side | livekit/livekit_composite | DeepWiki | Webhooks | LiveKit docs
l
is there any SDK for the metrics part in node js such as for bandwith , total downstream, total upstream etc
t
Short answer: there’s no separate “metrics SDK” for Node — use the Cloud Analytics API for session-level metrics (bandwidth in/out, publishBps, connectionMinutes, participant lists) and use SDK/event hooks for real‑time per‑participant/agent metrics (track bitrates, packet loss, RTT, and agent pipeline metrics like STT/TTS/LLM latencies). What to use and how (Cloud) 1) Analytics API — post‑call / historical metrics (recommended for bandwidth totals) • Generate a server JWT (analytics scope). • List sessions: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions?limit=50&amp;start={ISO}&amp;end={ISO} • Session detail: GET https://cloud-api.livekit.io/api/project/{PROJECT_ID}/sessions/{SESSION_ID} • Useful fields: bandwidthIn (bytes), bandwidthOut (bytes), publishBps, participants[], quality/time-series. 2) Real-time per-call metrics (in-process) • In your agent/backend, capture participant/track stats and SDK events and push to your monitoring system: ◦ track publish bps, packetsLost, rtt, frameRate ◦ participant connect/disconnect times • For agent pipeline metrics, subscribe to
metrics_collected
events from STT/TTS/LLM components in
@livekit/agents
. 3) Programmatic examples (Node/TS) • Analytics API (fetch totals):
Copy code
// 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):
Copy code
// 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 docs
l
show me code example for top three aggregated totals or average sort of metric
t
Below is a concise TypeScript example that: • Generates an analytics JWT (server-side) • Calls the LiveKit Cloud Analytics API to list sessions in a time window • Aggregates top 3 metrics (by total bandwidthIn, total bandwidthOut, average publishBps) • Prints the top three sessions for each metric Requirements: • npm install node-fetch@2 livekit-server-sdk dotenv • Set env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, PROJECT_ID src/analytics-aggregates.ts
Copy code
import 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?
l
by top three i meant the best three metrics to showcase in terms of average or aggregate]
t
Great — for agent call reporting, the three metrics I recommend showcasing (with suggested aggregation) are: 1) Average call bandwidth (upstream + downstream) • Why: Shows per-call network cost and overall media load. • Aggregate: report average and p95 of (bandwidthIn + bandwidthOut) per session over the window; also show total bytes for billing. 2) Average publish bitrate (agent’s outbound audio bitrate) • Why: Reflects audio quality and encoding settings; useful for diagnosing poor uplink or low-quality audio. • Aggregate: compute mean and median publishBps per agent across sessions; show time-series average during calls. 3) Packet loss rate (percent) or connection quality score • Why: Directly correlates with perceived call quality (glitches, dropouts). • Aggregate: report average and p95 packet loss per participant (or per session) and percentage of calls above an acceptable threshold (e.g., >2% loss). Optional helpful extras (single-line) • RTT / latency average — useful for debugging interactivity issues. • Call duration / connection minutes — useful for usage/billing dashboards. How to present • Show a small dashboard card per metric with: current window average, p95 (or median), and trend (delta vs previous window). • For troubleshooting, link top offending sessions/roomIds (from Analytics API) so you can inspect transcripts/logs. If you want, I can produce a concise aggregation query and TypeScript snippet that computes these three metrics from the Analytics API and returns averages + p95 + top offending sessions. Which time window (last hour, 24h, 7d) should I use?
l
Write sample code in node js for the above
t
Below is a concise Node.js (TypeScript) example that: • Calls the LiveKit Cloud Analytics API to fetch sessions in a time window • Aggregates three key metrics: average call bandwidth (up+down), average publish bitrate, and packet loss rate • Computes mean and p95 for each metric and lists top offending sessions Requirements • npm i node-fetch@2 livekit-server-sdk dotenv • .env: PROJECT_ID, LIVEKIT_API_KEY, LIVEKIT_API_SECRET src/analytics-aggregates.ts
Copy code
import 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?
l
tried making api call from postman got this instead| { "success": false, "message": "Failed to fetch system analytics", "error": "Analytics API failed: 401 go-jose/go-jose: compact JWS format must have three parts\n" } what could be wrong
t
That 401 + "compact JWS format must have three parts" means the Authorization header you sent to the Analytics API is not a valid JWT. Common causes and fixes: 1) Token generation — must be a JWT (three dot-separated parts) • Use AccessToken from livekit-server-sdk and add the analytics grant:
Copy code
import { 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=...&amp;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.
l
router.get('/system', async (req, res) => { try { const hours = parseInt(req.query.hours as string) || 24; const metrics = await analyticsService.aggregateWindow(hours); const stats = await analyticsService.getSystemStats(hours); res.json({ success: true, data: { ...stats, metrics, }, }); } catch (error: any) { console.error('Failed to get system analytics:', error); res.status(500).json({ success: false, message: 'Failed to fetch system analytics', error: error.message, }); } }); async aggregateWindow(hours = 24): Promise<SessionMetrics> { const end = new Date(); const start = new Date(end.getTime() - hours * 3600 * 1000); const rawSessions = await this.fetchSessions( start.toISOString(), end.toISOString(), 1000 ); const sessions = rawSessions.map((s) => this.normalizeSession(s)); // Metric arrays const bandwidthTotals = sessions.map((s) => s.bandwidthTotal); const publishArr = sessions.map((s) => s.publishBps); const lossArr = sessions.map((s) => s.packetLossPct); // Compute stats return { bandwidth: { meanBytes: this.mean(bandwidthTotals), p95Bytes: this.p95(bandwidthTotals), totalBytes: bandwidthTotals.reduce((a, b) => a + b, 0), topSessions: this.topNBy(sessions, (s) => s.bandwidthTotal, 5), }, publishBps: { meanBps: this.mean(publishArr), p95Bps: this.p95(publishArr), topSessions: this.topNBy(sessions, (s) => s.publishBps, 5), }, packetLossPct: { meanPct: this.mean(lossArr), p95Pct: this.p95(lossArr), topSessions: this.topNBy(sessions, (s) => s.packetLossPct, 5), }, window: { start: start.toISOString(), end: end.toISOString(), sessionsCount: sessions.length, }, }; } async getSystemStats(hours = 24): Promise<{ totalSessions: number; totalDuration: number; avgSessionDuration: number; totalBandwidth: number; avgBandwidth: number; avgPacketLoss: number; peakConcurrentSessions: number; }> { const end = new Date(); const start = new Date(end.getTime() - hours * 3600 * 1000); const rawSessions = await this.fetchSessions( start.toISOString(), end.toISOString(), 1000 ); const sessions = rawSessions.map((s) => this.normalizeSession(s)); const totalDuration = sessions.reduce((sum, s) => sum + s.durationSec, 0); const totalBandwidth = sessions.reduce( (sum, s) => sum + s.bandwidthTotal, 0 ); const totalPacketLoss = sessions.reduce( (sum, s) => sum + s.packetLossPct, 0 ); return { totalSessions: sessions.length, totalDuration, avgSessionDuration: sessions.length > 0 ? totalDuration / sessions.length : 0, totalBandwidth, avgBandwidth: sessions.length > 0 ? totalBandwidth / sessions.length : 0, avgPacketLoss: sessions.length > 0 ? totalPacketLoss / sessions.length : 0, peakConcurrentSessions: 0, // Would need more complex calculation }; } }
t
Thanks — the 401 and "compact JWS format must have three parts" mean the Analytics API is receiving an invalid Authorization token. Likely causes and quick fixes: 1) Token must be a JWT created with AccessToken and include the analytics grant • Correct snippet:
Copy code
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,
  });
  (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.
l
example of token eyJhbGciOiJIUzI1NiJ9.eyJ2aWRlbyI6eyJhbmFseXRpY3MiOnRydWV9LCJpc3MiOiJBUEl1bXlod2VoQVVTZ0QiLCJleHAiOjE3NjYxODAxMDYsIm5iZiI6MCwic3ViIjoiYW5hbHl0aWNzIn0.tZzDTgvWd7eou4BVr3SE303Mhkzi7udtEgN5gi77wXw
t
Thanks — that token shows the analytics grant is nested under
video
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:
Copy code
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=...&amp;end=... If you want, paste a redacted token (first/last 6 chars visible) after regenerating and I’ll confirm the payload shape.