broad-librarian-92914
12/16/2025, 4:35 PMtall-airline-16756
12/16/2025, 4:36 PMtall-airline-16756
12/16/2025, 4:36 PMbroad-librarian-92914
12/16/2025, 4:41 PMtall-airline-16756
12/16/2025, 4:42 PMlivekit-server-sdk.
◦ Token should include VideoGrant with room join, ttl configured (short-lived, e.g., 10m).
◦ Return token + LiveKit server URL to client.
1. Frontend: connect to LiveKit room (Next.js React)
◦ Use @livekit/components-react or livekit-client to join room with token.
◦ Publish microphone audio track and subscribe to agent audio.
◦ Use LiveKit DataChannel to exchange JSON events (e.g., start/interview-step, candidate metadata, transcript chunks).
1. Agent backend: Gemini / Vertex Live integration
◦ Run an AgentSession (Python or Node) using LiveKit agents plugin for Google:
▪︎ Configure google.realtime.RealtimeModel for the chosen Gemini/Vertex model.
▪︎ Enable STT and TTS modalities as needed.
▪︎ Provide interview instructions (role, tone, expected Q/A flow).
◦ Connect the agent to the LiveKit room so it appears as a participant and can speak/listen.
◦ Use turn-detection (VAD) or agent-provided turn signals to manage speaker handoff.
1. Real-time context flow (Redis + data channels)
◦ Keep ephemeral interview state in Redis keyed by `interview:{roomId}`:
▪︎ current_question_index, expected_next_action, turn_state, partial_transcript, timestamps.
◦ Push state updates to clients via LiveKit DataChannel messages so interviewer UI and agent remain in sync.
◦ Persist important checkpoints (question asked, candidate response summary) in Supabase via Prisma periodically or at question boundaries.
1. Recording & Egress (post-interview)
◦ Start egress for a room composite (MP4/HLS) or record per-participant audio.
◦ On egress_ended webhook, save file to Supabase Storage or S3 and update Prisma record.
◦ Optionally run post-processing: high-quality STT, redaction, and summary generation.
1. Post-interview processing and storage
◦ Persist final transcript, timestamps, summary, scoring metadata to Supabase via Prisma.
◦ Store links to recorded media and derived assets (analysis, highlights).
3) Context management details
• Live session: in-memory and Redis store turn-level context and immediate history for low-latency decisions.
• Long-term storage: After each interview or meaningful checkpoint, write transcripts, summaries, and conversational context to Supabase.
• How agents maintain context: AgentSession holds session-scoped history while active. For rehydration across sessions, retrieve stored context from Supabase/Redis and feed it into the agent when rejoining.
4) Session/time limits
• Rooms themselves have no enforced hard duration limit — participants can stay connected as long as needed.
• Egress (recording) has limits: common constraints are 12 hours for HLS/MP4-type outputs and shorter window limits for certain file outputs; rotate or re-trigger egress for longer recordings.
• Billing is metered by connection minutes and egress usage; monitor quotas per your LiveKit plan.
5) Cost considerations (summary)
• LiveKit: billed by connection minutes, egress (recording) storage, and any additional infra features. Build plan includes some free agent minutes/credits.
• Google (Gemini/Vertex Live): costs for real-time STT, model inference (LLM usage), and TTS. These are usage-based (per-minute or per-token where applicable).
• Estimated combined runtime cost can be a few cents per active minute depending on model choice, TTS frequency, and recording needs. Monitor actual usage in billing dashboards to refine.
6) Security & best practices
• Issue short-lived tokens from server; do not embed LiveKit secrets in frontend.
• Use role-based grants if you need interviewer-only controls vs. candidate.
• Encrypt stored recordings and limit access in Supabase.
• Sanitize transcripts before sending to downstream services if needed.
7) Next steps I can provide (choose one)
• Full starter repo: complete Next.js + token endpoint + sample room + AgentSession example (Python).
• Exact code for token endpoint and client connect (TypeScript).
• AgentSession example with google.realtime.RealtimeModel and TTS/STT config (Python).
• Redis key patterns and sample code for context management.
• Egress start/stop example and webhook handler.
Which of those would you like first? If you want code, tell me whether you prefer Node or Python for the agent backend and I’ll give the token endpoint + agent hookup next.
Sources: Authentication | LiveKit docs | Expo quickstart | LiveKit docs | Gemini Live API plugin | LiveKit docs | Quotas and limits | LiveKit docs | Egress output types | LiveKit docsbroad-librarian-92914
12/16/2025, 4:48 PMtall-airline-16756
12/16/2025, 4:48 PMGOOGLE_APPLICATION_CREDENTIALS (recommended for Vertex)?
Answer those 3 and I’ll produce:
• A GitHub-ready repo structure (frontend Next.js + backend Node.js agent)
• Complete code for token endpoint, LiveKit client connection, AgentSession using the Google plugin, Redis context handling, and egress/webhook handler
• Environment variable list and .env.example
• Setup and run commands and deployment notes
Which options do you choose?
Sources: App templates | LiveKit docs | Gemini Live API plugin | LiveKit docs | client-sdk-js | livekit/livekit_composite | DeepWikibroad-librarian-92914
12/16/2025, 4:58 PMtall-airline-16756
12/16/2025, 4:59 PMimport { NextApiRequest, NextApiResponse } from 'next';
import { AccessToken, VideoGrant } from 'livekit-server-sdk';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const identity = req.query.identity as string || `user-${Math.random().toString(36).slice(2,8)}`;
const roomName = req.query.room as string || 'interview-room';
const apiKey = process.env.NEXT_PUBLIC_LIVEKIT_API_KEY!;
const apiSecret = process.env.NEXT_PUBLIC_LIVEKIT_API_SECRET!;
const at = new AccessToken(apiKey, apiSecret, { identity });
const grant: VideoGrant = { roomJoin: true, room: roomName, canPublish: true, canSubscribe: true };
at.addGrant(grant);
at.ttl = '10m';
res.status(200).json({ token: at.toJwt(), wsUrl: process.env.NEXT_PUBLIC_LIVEKIT_URL });
}
Note: In production keep API secret on a secure server — here for local dev the frontend token route expects those env vars.
3) frontend/src/components/Room.tsx
• Connect to LiveKit room, publish mic, and show audio renderer. Use DataChannel to exchange simple JSON messages (context updates, transcript chunks).
'use client';
import React, { useEffect, useState } from 'react';
import { connect, Room } from 'livekit-client';
export default function RoomComponent({ token, serverUrl }: { token: string; serverUrl: string; }) {
const [room, setRoom] = useState<Room | null>(null);
useEffect(() => {
let r: Room;
(async () => {
r = await connect(serverUrl, token, { autoSubscribe: true });
setRoom(r);
const pc = await navigator.mediaDevices.getUserMedia({ audio: true });
await r.localParticipant.publishTrack(pc.getAudioTracks()[0]);
// DataChannel example:
r.dataChannels.open('app');
r.dataChannels.on('message', (data, participant) => {
// handle incoming JSON messages
});
})();
return () => { r?.disconnect(); };
}, [token, serverUrl]);
return <div>{room ? <p>Connected</p> : <p>Connecting…</p>}</div>;
}
4) frontend/src/lib/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
Frontend usage: call /api/token to get token + serverUrl, pass to Room component.
Agent (Node.js) — key files
1) agent/package.json { "name": "agent", "private": true, "scripts": { "dev": "ts-node-dev --respawn src/index.ts", "build": "tsc", "start": "node dist/index.js" }, "dependencies": { "@livekit/agents": "^1.0.0", "@livekit/agents-plugin-google": "^1.0.0", "livekit-server-sdk": "^2.0.0", "@supabase/supabase-js": "^2.0.0", "ioredis": "^5.0.0", "dotenv": "^16.0.0" }, "devDependencies": { "ts-node-dev": "^2.0.0", "typescript": "^5.0.0" } }
2) agent/src/livekitClient.ts
• Helper to connect agent to LiveKit as a participant (server-side) if needed (or use Agents framework entry).
import { RoomServiceClient } from 'livekit-server-sdk';
export function createRoomClient() {
return new RoomServiceClient(process.env.LIVEKIT_URL!, process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!);
}
3) agent/src/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
4) agent/src/agent.ts
• AgentSession using Google RealtimeModel plugin. Saves transcripts to Supabase and uses Redis for ephemeral context.
import { AgentSession } from '@livekit/agents';
import * as google from '@livekit/agents-plugin-google';
import IORedis from 'ioredis';
import { supabase } from './supabaseClient';
const redis = new IORedis(process.env.REDIS_URL);
export async function startAgent() {
const session = new AgentSession({
llm: new google.realtime.RealtimeModel({
model: 'gemini-2.5-flash-native-audio-preview-09-2025', // pick model available to you
voice: 'Puck',
instructions: 'You are a professional interviewer. Ask the candidate questions and wait for answers.',
apiKey: process.env.GOOGLE_API_KEY,
}),
stt: 'google/speech-to-text',
tts: 'google/text-to-speech',
});
session.on('turn_end', async (ev) => {
const roomId = ev.room;
const partial = await session.getTranscript(); // pseudo-method; use session APIs to fetch transcript
await redis.set(`interview:${roomId}:partial`, JSON.stringify(partial));
});
session.on('session_end', async (ev) => {
const roomId = ev.room;
const full = await session.getFinalTranscript(); // pseudo-method
await supabase.from('interviews').insert({ room_id: roomId, transcript: full });
});
// start listening for rooms / connect session to LiveKit; actual hook depends on Agents framework entry points
}
Note: replace pseudo-methods with the actual AgentSession API calls from @livekit/agents; this shows where to save context and final transcripts.
5) agent/src/index.ts
import 'dotenv/config';
import { startAgent } from './agent';
startAgent().catch(err => { console.error(err); process.exit(1); });
Supabase schema (SQL)
• interviews table:
CREATE TABLE interviews ( id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), room_id text, transcript text, created_at timestamptz DEFAULT now() );
Run locally
1. Start Redis (local) or use a hosted Redis.
2. Start Supabase (or use hosted).
3. Fill env files in frontend/.env.local and agent/.env.
4. Install dependencies:
• frontend: cd frontend && pnpm install
• agent: cd agent && pnpm install
1. Start:
• agent: cd agent && pnpm run dev
• frontend: cd frontend && pnpm run dev
1. Open frontend at http://localhost:3000, it calls /api/token -> join room. Agent must be running to join the same room and respond.
Notes on session limits, context, and cost
• Room duration: no hard enforced limit; rooms can run indefinitely while participants stay connected.
• Egress (recording) limits: recordings/HLS typically limited (e.g., 12 hours for HLS/MP4); rotate egress for longer recordings.
• Context: AgentSession holds live session context while active; persist to Redis for ephemeral state and Supabase for long-term storage and rehydration.
• Cost: usage-based — LiveKit meters connection minutes and egress; Google Gemini/Vertex charges for real-time STT, LLM inference (tokens/min), and TTS. Monitor dashboards to refine actual costs.
I can:
• generate full file contents ready to paste into files (TSX/TS) if you want that now,
• or push a GitHub-compatible zip scaffold with all files.
Which do you prefer: full file contents here, or a downloadable repo scaffold?
Sources: React quickstart | LiveKit docs | examples/voice_agents | livekit/livekit_composite | DeepWiki | Google Gemini LLM plugin guide | LiveKit Docs | Gemini Live API plugin | LiveKit docs | examples/other | livekit/livekit_composite | DeepWikibroad-librarian-92914
12/16/2025, 5:01 PMtall-airline-16756
12/16/2025, 5:02 PMbroad-librarian-92914
12/16/2025, 5:03 PMtall-airline-16756
12/16/2025, 5:04 PM// app/api/token/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';
import { AccessToken, VideoGrant } from 'livekit-server-sdk';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const identity = url.searchParams.get('identity') || `user-${Math.random().toString(36).slice(2,8)}`;
const room = url.searchParams.get('room') || 'interview-room';
const apiKey = process.env.LIVEKIT_API_KEY!;
const apiSecret = process.env.LIVEKIT_API_SECRET!;
const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL!;
const at = new AccessToken(apiKey, apiSecret, { identity });
const grant: VideoGrant = { roomJoin: true, room, canPublish: true, canSubscribe: true };
at.addGrant(grant);
at.ttl = '10m';
return NextResponse.json({ token: at.toJwt(), wsUrl });
}
If you use pages/api, put equivalent in pages/api/token.ts and export default handler.
B. Client Room component — app/realtime/RoomClient.tsx
'use client';
import React, { useEffect, useState } from 'react';
import { connect, Room } from 'livekit-client';
export default function RoomClient({ roomName = 'interview-room' }: { roomName?: string }) {
const [status, setStatus] = useState('idle');
useEffect(() => {
let room: Room | undefined;
(async () => {
setStatus('fetching-token');
const res = await fetch(`/api/token?room=${roomName}`);
const { token, wsUrl } = await res.json();
setStatus('connecting');
room = await connect(wsUrl, token, { autoSubscribe: true });
setStatus('connected');
// publish mic
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
await room.localParticipant.publishTrack(stream.getAudioTracks()[0]);
} catch (e) {
console.error('mic error', e);
}
// example data channel usage
room.dataChannels.open('app');
room.dataChannels.on('message', (data, participant) => {
try {
const json = JSON.parse(new TextDecoder().decode(data as ArrayBuffer));
console.log('data message', json);
} catch {
console.log('message', data);
}
});
})();
return () => { room?.disconnect(); };
}, [roomName]);
return <div>LiveKit: {status}</div>;
}
C. Supabase client (frontend) — lib/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
D. Add a small UI page to join — app/realtime/page.tsx
import RoomClient from './RoomClient';
export default function Page() {
return (
<main>
<h1>Interview Room</h1>
<RoomClient roomName="interview-room" />
</main>
);
}
4) Agent process (runs separately in same repo) Create folder agent/ with its own package.json.
A. agent/package.json
{
"name": "agent",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts"
}
}
B. agent/.env.example LIVEKIT_API_KEY=your_livekit_api_key LIVEKIT_API_SECRET=your_livekit_api_secret LIVEKIT_URL=wss://your-livekit-host GOOGLE_API_KEY=your_google_api_key SUPABASE_URL=https://your-supabase-url SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key REDIS_URL=redis://localhost:6379
C. agent/src/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
D. agent/src/index.ts (agent bootstrap)
import 'dotenv/config';
import { AgentSession } from '@livekit/agents';
import * as google from '@livekit/agents-plugin-google';
import IORedis from 'ioredis';
import { supabase } from './supabaseClient';
const redis = new IORedis(process.env.REDIS_URL);
async function start() {
const session = new AgentSession({
llm: new google.realtime.RealtimeModel({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
voice: 'Puck',
instructions: 'You are a professional interviewer. Ask clear technical questions and wait for responses.',
apiKey: process.env.GOOGLE_API_KEY,
}),
stt: 'google/speech-to-text',
tts: 'google/text-to-speech',
});
// Hook onto session events (pseudo-API names; adapt if needed)
session.on('turn_end', async (ev: any) => {
const { room } = ev;
const partial = await session.getTranscript?.() || null;
await redis.set(`interview:${room}:partial`, JSON.stringify(partial));
});
session.on('session_end', async (ev: any) => {
const { room } = ev;
const final = await session.getFinalTranscript?.() || null;
await supabase.from('interviews').insert({ room_id: room, transcript: final });
await redis.del(`interview:${room}:partial`);
});
// Start listening / connect agent to LiveKit rooms automatically if using Agents framework runner
await session.listen(); // if available in your installed @livekit/agents version
console.log('Agent started');
}
start().catch((err) => { console.error(err); process.exit(1); });
Note: adapt session.listen() / event names to the exact @livekit/agents API version you installed; the code shows where to persist partial transcripts to Redis and final transcripts to Supabase.
5) Prisma / Supabase schema Add table to Supabase: CREATE TABLE interviews ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), room_id text, transcript text, created_at timestamptz DEFAULT now() );
6) Redis patterns
• Ephemeral state: key interview:{roomId}:partial
• Lock/turn: interview:{roomId}:turn store JSON { speaker: 'agent'|'candidate', ts }
7) Egress & recording Start egress from your agent or via LiveKit REST when interview is running:
• Use RoomServiceClient from livekit-server-sdk or Agents egress API to create a room composite recording to S3 or Supabase storage.
• Remember egress recording limits (rotate after ~12 hours if needed).
8) Env vars (repo root .env/local & agent/.env)
• LIVEKIT_API_KEY, LIVEKIT_API_SECRET, NEXT_PUBLIC_LIVEKIT_URL
• NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY
• SUPABASE_SERVICE_ROLE_KEY (agent)
• GOOGLE_API_KEY
• REDIS_URL
9) Run locally
• Start Redis and Supabase (or use hosted).
• Start agent: pnpm start:agent (runs agent/dev script)
• Start Next.js dev as usual: pnpm dev
• Open your interview page and join; agent must join same room and will appear as a participant.
10) Notes & next steps I can provide
• I used minimal example code where the AgentSession APIs may have slight naming differences — I can convert event handlers to exact @livekit/agents method names if you tell me your installed versions.
• I can paste full exact files (complete TS/TSX) for each item above to copy/paste.
• I can add egress start code, webhook handler for egress_ended, and example Redis key helpers.
Do you want me to:
• Paste full file contents for every file above (copy/paste-ready)?
• Or adapt the agent event code to the exact @livekit/agents version you have installed (please paste package.json)? Sources: Web and mobile frontends | LiveKit docs | Gemini Live API plugin | LiveKit docs | React quickstart | LiveKit docs | OpenAI and LiveKit partner to turn Advanced Voice into an APIbroad-librarian-92914
12/16/2025, 5:15 PMtall-airline-16756
12/16/2025, 5:15 PMimport { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
agent/redisClient.ts
import IORedis from 'ioredis';
export const redis = new IORedis(process.env.REDIS_URL);
agent/agent.ts
import { AgentSession } from '@livekit/agents';
import * as google from '@livekit/agents-plugin-google';
import { supabase } from './supabaseClient';
import { redis } from './redisClient';
export async function startAgent() {
const session = new AgentSession({
llm: new google.realtime.RealtimeModel({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
voice: 'Puck',
apiKey: process.env.GOOGLE_API_KEY,
instructions: 'You are a professional interviewer.',
}),
stt: 'google/speech-to-text',
tts: 'google/text-to-speech',
});
session.on('turn_end', async (ev: any) => {
const room = ev.room;
const partial = await session.getTranscript?.() || null;
await redis.set(`interview:${room}:partial`, JSON.stringify(partial));
});
session.on('session_end', async (ev: any) => {
const room = ev.room;
const final = await session.getFinalTranscript?.() || null;
await supabase.from('interviews').insert({ room_id: room, transcript: final });
await redis.del(`interview:${room}:partial`);
});
// start listening/attach to LiveKit rooms — use agent framework runner or session.listen() if available
await session.listen?.();
console.log('Agent started');
}
agent/index.ts
import 'dotenv/config';
import { startAgent } from './agent';
startAgent().catch(e => { console.error(e); process.exit(1); });
3) Add scripts to your single package.json In repo root package.json add:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"agent:dev": "ts-node-dev --respawn --transpile-only agent/index.ts"
}
Run agent locally with: pnpm run agent:dev
4) Environment variables Keep agent secrets out of client-exposed .env.local. Add to repo root (or a .env for dev): LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL, GOOGLE_API_KEY, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, REDIS_URL
5) Process management for production
• Run agent as a separate process (systemd, Docker container, or a process manager like PM2) alongside your Next.js server.
• If deploying to a single host, run Next.js and agent as separate services. If using serverless hosting for Next.js (Vercel), deploy agent elsewhere (container/VM).
6) Caveats & best practices
• Keeping agent code in same repo is fine for development; keep secrets for agent only on server and never expose them to client.
• Use the service role key for Supabase in agent; use anon key client-side.
• Monitor memory/CPU of agent — model interactions can be resource-heavy.
• If you prefer isolation later (CI, deployments), you can extract agent folder into its own service; your repo layout already supports that.
If you want, I can:
• Paste the exact ready-to-copy files (full TS) for the three agent files above, or
• Show a Dockerfile and PM2/PM config for running the agent in production.
Which would you like next?broad-librarian-92914
12/17/2025, 10:18 AMtall-airline-16756
12/17/2025, 10:19 AMimport 'dotenv/config';
import { startAgentProcess } from './agent';
async function main() {
try {
await startAgentProcess();
console.log('Agent process started');
} catch (err) {
console.error('Agent failed to start', err);
process.exit(1);
}
}
main();
File: agent/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
File: agent/redisClient.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || '<redis://localhost:6379>');
redis.on('connect', () => console.log('Redis connected'));
redis.on('error', (e) => console.error('Redis error', e));
export { redis };
File: agent/agent.ts
import { AgentSession } from '@livekit/agents';
import * as google from '@livekit/agents-plugin-google';
import { supabase } from './supabaseClient';
import { redis } from './redisClient';
/**
* Simple Interview Agent runner that:
* - uses Gemini Live (via @livekit/agents-plugin-google)
* - saves ephemeral context to Redis
* - persists responses and session metadata to Supabase
*
* Note: adapt event names and methods to the exact @livekit/agents version if necessary.
*/
export async function startAgentProcess() {
const session = new AgentSession({
llm: new google.realtime.RealtimeModel({
model: 'gemini-2.0-flash-exp',
voice: 'Puck',
temperature: 0.7,
instructions: `You are an AI interviewer. Ask one question at a time, wait for the candidate's complete answer, ask brief follow-ups if needed, be professional and encouraging.`,
apiKey: process.env.GOOGLE_API_KEY,
}),
stt: 'google/speech-to-text',
tts: 'google/text-to-speech',
});
// When agent commits speech (agent spoke)
session.on('agent_speech_committed', async (ev: any) => {
try {
const room = ev.room ?? ev.roomName ?? ev.roomId;
const text = ev.transcript ?? ev.text ?? null;
if (!room || !text) return;
const key = `interview:${room}:responses`;
await redis.lpush(key, JSON.stringify({ type: 'agent', text, ts: Date.now() }));
await redis.expire(key, 3600);
} catch (e) { console.error('agent_speech_committed handler error', e); }
});
// When candidate/user speech is committed (user finished speaking)
session.on('user_speech_committed', async (ev: any) => {
try {
const room = ev.room ?? ev.roomName ?? ev.roomId;
const text = ev.transcript ?? ev.text ?? null;
if (!room || !text) return;
// push to Redis
const key = `interview:${room}:responses`;
await redis.lpush(key, JSON.stringify({ type: 'candidate', text, ts: Date.now() }));
await redis.expire(key, 3600);
// persist to Supabase (simple schema)
await supabase.from('interview_responses').insert({
room_id: room,
response: text,
created_at: new Date().toISOString(),
});
// optional: basic follow-up logic (word count)
const wordCount = text.trim().split(/\s+/).length;
if (wordCount < 8) {
await session.say('Thanks — could you elaborate a bit more on that?');
} else {
// move to next question (simple placeholder)
await session.say('Great. Next question: Tell me about a challenging project you worked on.');
}
} catch (e) { console.error('user_speech_committed handler error', e); }
});
// session end or cleanup hook (event name may vary)
session.on('session_end', async (ev: any) => {
try {
const room = ev.room ?? ev.roomName ?? ev.roomId;
if (!room) return;
const key = `interview:${room}:responses`;
const items = await redis.lrange(key, 0, -1);
const transcript = items.map((i) => JSON.parse(i)).reverse().map((r: any) => `${r.type}: ${r.text}`).join('\n');
await supabase.from('interviews').insert({
room_id: room,
transcript,
created_at: new Date().toISOString(),
});
await redis.del(key);
} catch (e) { console.error('session_end handler error', e); }
});
// Start listening for rooms/requests. The actual startup depends on @livekit/agents runner usage.
// If your version supports session.listen() or a runner, call it. Otherwise the package's CLI might be used.
if (typeof (session as any).listen === 'function') {
await (session as any).listen();
console.log('AgentSession listening');
} else {
console.log('AgentSession ready (no listen method detected). Ensure your @livekit/agents usage starts sessions appropriately.');
}
}
File: app/api/token/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { AccessToken, VideoGrant } from 'livekit-server-sdk';
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const identity = url.searchParams.get('identity') || `user-${Math.random().toString(36).slice(2,8)}`;
const room = url.searchParams.get('room') || 'interview-room';
const apiKey = process.env.LIVEKIT_API_KEY!;
const apiSecret = process.env.LIVEKIT_API_SECRET!;
const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL!;
const at = new AccessToken(apiKey, apiSecret, { identity });
const grant: VideoGrant = { roomJoin: true, room, canPublish: true, canSubscribe: true };
at.addGrant(grant);
at.ttl = '10m';
return NextResponse.json({ token: at.toJwt(), wsUrl });
} catch (err) {
console.error('token route error', err);
return NextResponse.json({ error: 'token generation failed' }, { status: 500 });
}
}
File: app/realtime/RoomClient.tsx
'use client';
import React, { useEffect, useState } from 'react';
import { connect, Room } from 'livekit-client';
export default function RoomClient({ roomName = 'interview-room' }: { roomName?: string }) {
const [status, setStatus] = useState('idle');
useEffect(() => {
let room: Room | undefined;
(async () => {
setStatus('fetching token');
const res = await fetch(`/api/token?room=${roomName}`);
const json = await res.json();
const { token, wsUrl } = json;
setStatus('connecting');
room = await connect(wsUrl, token, { autoSubscribe: true });
setStatus('connected');
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
await room.localParticipant.publishTrack(stream.getAudioTracks()[0]);
} catch (e) {
console.error('microphone error', e);
}
room.dataChannels.open('app');
room.dataChannels.on('message', (data, participant) => {
try {
const text = typeof data === 'string' ? data : new TextDecoder().decode(data as ArrayBuffer);
console.log('data channel message', text);
} catch { console.log('raw data', data); }
});
})();
return () => { room?.disconnect(); };
}, [roomName]);
return <div>Interview Room — status: {status}</div>;
}
File: lib/supabaseClient.ts (frontend)
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
Prisma / Supabase schema (SQL) Run this in your Supabase SQL editor or via migration:
create table if not exists interviews (
id uuid primary key default gen_random_uuid(),
room_id text,
transcript text,
created_at timestamptz default now()
);
create table if not exists interview_responses (
id uuid primary key default gen_random_uuid(),
room_id text,
response text,
created_at timestamptz default now()
);
Run locally
1. Start Redis (local or hosted).
2. Ensure Supabase project exists and you have SERVICE_ROLE key.
3. Add env vars to .env (server-side) and .env.local (client-safe NEXT_PUBLIC_*).
4. Install packages (see list above).
5. Start agent: pnpm run agent:dev
6. Start Next.js: pnpm dev
7. Open interview page (e.g., /realtime) and join; agent should auto-join/respond if AgentSession listener started.
Caveats & next steps
• Some event names and methods in @livekit/agents may vary by exact package version. If you see TypeScript errors or different event names, tell me your installed @livekit/agents version and I’ll adapt handlers precisely.
• Never expose LIVEKIT_API_SECRET or SUPABASE_SERVICE_ROLE_KEY to the browser.
• For production, run the agent as a separate process/service (container or VM) and secure env vars in your host.
• I can provide a Dockerfile and PM2 or systemd example to run the agent in production.
Do you want me to:
• Paste these files with exact imports adjusted to your TS config (yes/no)?
• Or adapt event names to your installed @livekit/agents version (paste your package.json)?broad-librarian-92914
12/17/2025, 10:50 AMtall-airline-16756
12/17/2025, 10:51 AMas any) to stop TS errors now, or
• generate corrected RoomClient.tsx + agent files targeting livekit-client v2.x and @livekit/agents v1.x.
Which do you want me to do after you paste package.json?broad-librarian-92914
12/17/2025, 10:51 AM{
"name": "smart_resume",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate",
"agent:dev": "ts-node-dev --respawn --transpile-only agent/index.ts"
},
"dependencies": {
"@ai-sdk/google": "^2.0.29",
"@livekit/agents": "^1.0.27",
"@livekit/agents-plugin-google": "^1.0.27",
"@livekit/components-react": "^2.9.17",
"@openai/agents": "^0.3.0",
"@prisma/client": "^6.19.0",
"@qdrant/js-client-rest": "^1.15.1",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.81.1",
"@upstash/redis": "^1.35.6",
"ai": "^5.0.89",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"livekit-client": "^2.16.1",
"livekit-server-sdk": "^2.15.0",
"lucide-react": "^0.553.0",
"next": "^15.5.7",
"openai": "^6.8.1",
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"prisma": "^6.19.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"recharts": "^3.4.1",
"redis": "^5.9.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}tall-airline-16756
12/17/2025, 10:52 AMRoom 2) @livekit/agents: use voice.AgentSession via the agent context / room, not a top-level import
Paste these files exactly.
1) app/api/token/route.ts
// app/api/token/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { AccessToken, VideoGrant } from 'livekit-server-sdk';
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const identity = url.searchParams.get('identity') || `user-${Math.random().toString(36).slice(2,8)}`;
const room = url.searchParams.get('room') || 'interview-room';
const apiKey = process.env.LIVEKIT_API_KEY!;
const apiSecret = process.env.LIVEKIT_API_SECRET!;
const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL!;
const at = new AccessToken(apiKey, apiSecret, { identity });
const grant: VideoGrant = { roomJoin: true, room, canPublish: true, canSubscribe: true };
at.addGrant(grant);
at.ttl = '10m';
return NextResponse.json({ token: at.toJwt(), wsUrl });
} catch (err) {
console.error('token route error', err);
return NextResponse.json({ error: 'token generation failed' }, { status: 500 });
}
}
2) app/realtime/RoomClient.tsx
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { Room } from 'livekit-client';
export default function RoomClient({ roomName = 'interview-room' }: { roomName?: string }) {
const roomRef = useRef<Room | null>(null);
const [status, setStatus] = useState('idle');
useEffect(() => {
let cancelled = false;
(async () => {
try {
setStatus('fetching token');
const res = await fetch(`/api/token?room=${roomName}`);
const json = await res.json();
const { token, wsUrl } = json;
if (cancelled) return;
setStatus('creating room');
const room = new Room();
roomRef.current = room;
// connect is instance method
await room.connect(wsUrl, token);
setStatus('connected');
// publish microphone
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const track = stream.getAudioTracks()[0];
await room.localParticipant.publishTrack(track);
} catch (e) {
console.warn('microphone publish failed', e);
}
// data channel example
room.dataChannels.open('app');
room.dataChannels.on('message', (data) => {
const text = typeof data === 'string' ? data : new TextDecoder().decode(data as ArrayBuffer);
console.log('data message', text);
});
} catch (e) {
console.error(e);
setStatus('error');
}
})();
return () => {
cancelled = true;
roomRef.current?.disconnect();
};
}, [roomName]);
return <div>Interview Room — status: {status}</div>;
}
3) agent/index.ts (agent entry — runs with your existing script)
// agent/index.ts
import 'dotenv/config';
import { defineAgent, JobContext } from '@livekit/agents';
import * as google from '@livekit/agents-plugin-google';
import { supabase } from './supabaseClient';
import { redis } from './redisClient';
export default defineAgent({
entry: async (ctx: JobContext) => {
// connect context room if not already connected by runner
// create AgentSession from the room context
const AgentSession = (ctx.room as any).AgentSession || (await import('@livekit/agents')).voice?.AgentSession;
if (!AgentSession) throw new Error('AgentSession not available on context.room');
const session = new AgentSession({
llm: new google.realtime.RealtimeModel({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
voice: 'Puck',
temperature: 0.7,
instructions: 'You are an AI interviewer. Ask one question at a time, wait for full answers.',
apiKey: process.env.GOOGLE_API_KEY,
}),
stt: 'google/speech-to-text',
tts: 'google/text-to-speech',
});
// basic handlers
session.on?.('user_speech_committed', async (ev: any) => {
const room = ev.room ?? ev.roomName ?? ctx.room.name;
const text = ev.transcript ?? ev.text ?? '';
if (!text) return;
await redis.lpush(`interview:${room}:responses`, JSON.stringify({ type: 'candidate', text, ts: Date.now() }));
await redis.expire(`interview:${room}:responses`, 3600);
await supabase.from('interview_responses').insert({ room_id: room, response: text, created_at: new Date().toISOString() });
const words = text.trim().split(/\s+/).length;
if (words < 8) await session.say('Could you elaborate a bit more on that?');
else await session.say('Thanks — next question: Describe a challenging project you worked on.');
});
session.on?.('agent_speech_committed', async (ev: any) => {
const room = ev.room ?? ev.roomName ?? ctx.room.name;
const text = ev.transcript ?? ev.text ?? '';
if (!text) return;
await redis.lpush(`interview:${room}:responses`, JSON.stringify({ type: 'agent', text, ts: Date.now() }));
await redis.expire(`interview:${room}:responses`, 3600);
});
session.on?.('session_end', async (ev: any) => {
const room = ev.room ?? ev.roomName ?? ctx.room.name;
const items = await redis.lrange(`interview:${room}:responses`, 0, -1);
const transcript = items.reverse().map(i => JSON.parse(i)).map((r: any) => `${r.type}: ${r.text}`).join('\n');
await supabase.from('interviews').insert({ room_id: room, transcript, created_at: new Date().toISOString() });
await redis.del(`interview:${room}:responses`);
});
await session.start?.({ room: ctx.room });
},
});
4) agent/supabaseClient.ts
// agent/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
5) agent/redisClient.ts
// agent/redisClient.ts
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL || '<redis://localhost:6379>');
redis.on('connect', () => console.log('Redis connected'));
redis.on('error', (e) => console.error('Redis error', e));
6) Add script to root package.json (you already have it) "agent:dev": "ts-node-dev --respawn --transpile-only agent/index.ts"
Notes and quick troubleshooting
• The agent code uses the AgentSession available from the agent context/room. Your @livekit/agents v1.x exposes the session via ctx.room.AgentSession in runner environments; I included a fallback to import the voice AgentSession if needed.
• If TypeScript still complains about missing types, add this to tsconfig "include": ["agent/*_/_"] (you already have agent files included earlier).
• If method/event names differ slightly with your exact @livekit/agents release, paste any new error messages and I’ll adapt handlers to the exact API.
• Keep LIVEKIT_API_SECRET and SUPABASE_SERVICE_ROLE_KEY server-only — never expose in client .env.local.
If you want, I’ll:
• paste full type-safe versions (with proper TS types) for your exact packages, or
• adapt agent to use defineAgent/cli runner if you prefer starting via LiveKit agent CLI.
Which would you like next?broad-librarian-92914
12/17/2025, 10:59 AMsmart_resume@0.1.0 agent:dev /Users/prathammungekar/Git Repo/smart_resume
ts-node-dev --respawn --transpile-only livekitagent/index.tssh: ts-node-dev: command not found
tall-airline-16756
12/17/2025, 10:59 AMts-node-dev isn’t installed (or not on PATH). To fix it quickly:
1) Install it as a dev dependency (pnpm): pnpm add -D ts-node-dev
2) Confirm your script points to the correct file path. Your package.json script uses: "agent:dev": "ts-node-dev --respawn --transpile-only livekitagent/index.ts"
Make sure the file exists at that path. If your agent file is at agent/index.ts, update the script to: "agent:dev": "ts-node-dev --respawn --transpile-only agent/index.ts"
3) Run the script: pnpm run agent:dev
Alternative (no install): npx ts-node-dev --respawn --transpile-only agent/index.ts
If you still get errors, paste:
• the exact agent script in package.json
• the agent file path (ls output)
and I’ll give the exact command or updated script.broad-librarian-92914
12/17/2025, 11:07 AM➜ smart_resume git:(main) ✗ pnpm run agent:dev
> smart_resume@0.1.0 agent:dev /Users/prathammungekar/Git Repo/smart_resume
> ts-node-dev -r tsconfig-paths/register --respawn --transpile-only livekitagent/index.ts
[INFO] 16:36:00 ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.2, typescript ver. 5.9.3)
Error: Cannot find module '/Users/prathammungekar/Git Repo/smart_resume/livekitagent/agent.js' imported from /Users/prathammungekar/Git Repo/smart_resume/livekitagent/index.ts
at finalizeResolution (node:internal/modules/esm/resolve:274:11)
at moduleResolve (node:internal/modules/esm/resolve:864:10)
at defaultResolve (node:internal/modules/esm/resolve:990:11)
at ModuleLoader.#cachedDefaultResolve (node:internal/modules/esm/loader:757:20)
at ModuleLoader.#resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:793:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:816:52)
at ModuleLoader.#cachedResolveSync (node:internal/modules/esm/loader:776:25)
at ModuleLoader.getModuleJobForRequire (node:internal/modules/esm/loader:474:50)
at ModuleJobSync.#link (node:internal/modules/esm/module_job:447:34)
at new ModuleJobSync (node:internal/modules/esm/module_job:420:17)
[ERROR] 16:36:00 Error: Cannot find module '/Users/prathammungekar/Git Repo/smart_resume/livekitagent/agent.js' imported from /Users/prathammungekar/Git Repo/smart_resume/livekitagent/index.tstall-airline-16756
12/17/2025, 11:07 AM.ts omitted and fix ts-node-dev config below.
2) Easiest reliable fix — run ts-node-dev with Node ESM interop by adding --esm and ensure imports use file extensions:
• Update script in package.json:
"agent:dev": "ts-node-dev --esm --respawn --transpile-only livekitagent/index.ts"
• Then change imports to include extensions if using ESM (e.g., import { startAgent } from './agent.js';).
3) Or use CommonJS-style resolution (no .js) — ensure tsconfig includes:
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"outDir": "dist"
}
}
And run without --esm. Keep imports like import { startAgent } from './agent';. Then use ts-node-dev without --esm.
4) Quick checklist to resolve now
• Inspect livekitagent/index.ts imports; if any import points to './agent', open livekitagent/agent.ts exists.
• If using ESM (recommended for Next 15), change imports to add .js and run ts-node-dev with --esm.
• If using CommonJS, ensure tsconfig module is CommonJS and run ts-node-dev without --esm.
5) If you want, paste the top of livekitagent/index.ts and livekitagent/agent.ts (first 20 lines) and I’ll give the exact edit to make it run immediately.
Which option do you want me to generate: add --esm + .js edits, or switch tsconfig to CommonJS and keep imports without extensions?broad-librarian-92914
12/17/2025, 11:17 AM<https://vertex-ai-1879pkoh.livekit.cloud/settings/regions> and <https://vertex-ai-1879pkoh.livekit.cloud/rtc/validate?access_token=>tall-airline-16756
12/17/2025, 11:17 AMkid / issuer fields (if present)
◦ exp is in the future
◦ grants contains roomJoin: true and correct room name (if set)
◦ `sub`/`identity` looks correct
6) Common gotchas
• Using NEXT_PUBLIC_ env for secrets: client-visible vars will not allow signing tokens. Signing must use LIVEKIT_API_SECRET on server only.
• Mismatched project: token signed by one project’s key cannot validate on a different LiveKit host.
• URL trailing slash/ http vs https mismatch when validating — use exact host returned by LiveKit dashboard.
• Token TTL expired — increase to a larger TTL temporarily to test.
7) If you use LiveKit Cloud agent/runner
• If the agent registers with LiveKit Cloud, ensure the same API key/secret are set in the agent process env and in the token endpoint, and that the agent has permission to create/validate sessions.
If you want, paste (redact secrets) either:
• Your token-generation function (server-side), or
• One example failing JWT (you can redact payload fields but keep header+payload to inspect claims),
and I’ll point out exactly what’s wrong.
Would you like me to check your token code or decode a sample token?broad-librarian-92914
12/17/2025, 11:21 AMimport 'dotenv/config';
import { NextRequest, NextResponse } from 'next/server';
import { AccessToken, VideoGrant } from 'livekit-server-sdk';
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const identity = url.searchParams.get('identity') || `user-${Math.random().toString(36).slice(2,8)}`;
const room = url.searchParams.get('room') || 'interview-room';
const apiKey = process.env.LIVEKIT_API_KEY!;
const apiSecret = process.env.LIVEKIT_API_SECRET!;
const wsUrl = process.env.LIVEKIT_URL!;
console.log("🚀 ~ GET ~ wsUrl:", wsUrl,apiKey, apiSecret)
const at = new AccessToken(apiKey, apiSecret, { identity });
const grant: VideoGrant = { roomJoin: true, room, canPublish: true, canSubscribe: true };
at.addGrant(grant);
at.ttl = '10m';
console.log("🚀 ~ GET ~ at:", at)
return NextResponse.json({ token: at.toJwt(), wsUrl });
} catch (err) {
console.error('token route error', err);
return NextResponse.json({ error: 'token generation failed' }, { status: 500 });
}
}
not getting token in ({ token: at.toJwt(), wsUrl }) only wsUrl is theretall-airline-16756
12/17/2025, 11:22 AMwsUrl is returned and token is missing, the AccessToken generation likely failed or the JWT is being omitted by NextResponse formatting. Try these quick checks and the fixed code:
1) Verify env vars are set and accessible to the route
• In your server environment (not client .env.local) confirm:
LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL
• Add a quick log (already have) — ensure apiKey and apiSecret are non-empty strings.
2) Replace your route with this explicit version to surface errors and return the raw token string:
app/api/token/route.ts
import 'dotenv/config';
import { NextRequest, NextResponse } from 'next/server';
import { AccessToken, VideoGrant } from 'livekit-server-sdk';
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const identity = url.searchParams.get('identity') || `user-${Math.random().toString(36).slice(2,8)}`;
const room = url.searchParams.get('room') || 'interview-room';
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
const wsUrl = process.env.LIVEKIT_URL;
if (!apiKey || !apiSecret || !wsUrl) {
console.error('Missing LiveKit env vars', { apiKey: !!apiKey, apiSecret: !!apiSecret, wsUrl: !!wsUrl });
return NextResponse.json({ error: 'server configuration error' }, { status: 500 });
}
const at = new AccessToken(apiKey, apiSecret, { identity });
const grant: VideoGrant = { roomJoin: true, room, canPublish: true, canSubscribe: true };
at.addGrant(grant);
at.ttl = 600; // seconds
const jwt = at.toJwt();
if (!jwt) {
console.error('AccessToken.toJwt() returned falsy');
return NextResponse.json({ error: 'token generation failed' }, { status: 500 });
}
return NextResponse.json({ token: jwt, wsUrl });
} catch (err) {
console.error('token route error', err);
return NextResponse.json({ error: 'token generation failed' }, { status: 500 });
}
}
3) Additional debugging
• After restarting dev, call the endpoint directly:
curl "http://localhost:3000/api/token?room=testroom" You should see JSON with token and wsUrl.
• If token still missing, decode logs: look for "Missing LiveKit env vars" or "AccessToken.toJwt() returned falsy".
4) Common causes if jwt is falsy
• Wrong API key/secret pair (from different LiveKit project).
• Secrets are undefined in the runtime (Next dev vs production env placement).
• Using NEXT_PUBLIC_ prefixed vars for secret (don’t).
• You set at.ttl to a non-number or invalid string — use seconds number as above.
Run the updated route and paste the endpoint output or any console logs that show up; I’ll take the next step.broad-librarian-92914
12/17/2025, 11:29 AM~ GET ~ at: AccessToken {
apiKey: 'API6ioefkASKxwA',
apiSecret: 'HiJgjL2ekf6l9ynzd5rIMm5dhiuiF4oP2PVp2Zp7coG',
grants: {
video: {
roomJoin: true,
room: 'interview-room',
canPublish: true,
canSubscribe: true
}
},
identity: 'user-8ig6rc',
ttl: 600
}tall-airline-16756
12/17/2025, 11:30 AMbroad-librarian-92914
12/17/2025, 11:51 AMbroad-librarian-92914
12/17/2025, 11:54 AMroom.dataChannels.open('app');
room.dataChannels.on('message', (data) => {
const text = typeof data === 'string' ? data : new TextDecoder().decode(data as ArrayBuffer);
console.log('data message', text);
});broad-librarian-92914
12/17/2025, 12:05 PMbroad-librarian-92914
12/17/2025, 3:07 PMsalmon-receptionist-35233
12/24/2025, 3:50 PMubatori-dw7b42p8). Token validates successfully, iOS app gets Validation response: "invalid token" on WebSocket connect, but Android/Web work fine with the same token and project. Using LiveKit Swift SDK 2.10.1. Have confirmed credentials match dashboard exactly. This appears to be the same iOS-specific regression.