LiveKit Integration
Guide for integrating LiveKit client in your frontend to connect to STELLA sessions.
Overview
STELLA uses LiveKit for real-time WebRTC communication. The frontend connects to LiveKit rooms to:
- Send/receive audio from agents
- Exchange data messages (transcripts, status updates)
- Track participant presence
Installation
npm install livekit-client
# or
yarn add livekit-client
Basic Connection
1. Get Join Token from Backend
async function getJoinToken(sessionId: string, userId: string, userName: string) {
const response = await fetch(`/api/sessions/${sessionId}/joinToken`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identity: userId,
name: userName,
}),
});
return response.json(); // { token, serverUrl, roomName }
}
2. Connect to LiveKit Room
import { Room } from 'livekit-client';
async function connectToSession(sessionId: string, userId: string, userName: string) {
// Get token from backend
const { token, serverUrl } = await getJoinToken(sessionId, userId, userName);
// Create room instance
const room = new Room({
adaptiveStream: true,
dynacast: true,
});
// Set up event listeners BEFORE connecting
room.on('connected', () => {
console.log('Connected to room!');
});
room.on('disconnected', () => {
console.log('Disconnected from room');
});
// Connect to room
await room.connect(serverUrl, token);
return room;
}
Audio Integration
Enable Microphone
import { Room, RoomEvent, Track } from 'livekit-client';
async function enableMicrophone(room: Room) {
try {
await room.localParticipant.setMicrophoneEnabled(true);
console.log('Microphone enabled');
} catch (error) {
console.error('Failed to enable microphone:', error);
throw error;
}
}
Receive Audio from Agent
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Audio) {
// Agent's audio track
const audioElement = track.attach();
document.body.appendChild(audioElement);
console.log(`Subscribed to ${participant.identity}'s audio`);
}
});
Audio Volume Meter
function setupVolumeMonitoring(room: Room) {
const audioContext = new AudioContext();
room.on(RoomEvent.LocalTrackPublished, (publication) => {
if (publication.kind === Track.Kind.Audio && publication.track) {
const mediaStream = new MediaStream([publication.track.mediaStreamTrack]);
const source = audioContext.createMediaStreamSource(mediaStream);
const analyser = audioContext.createAnalyser();
source.connect(analyser);
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
function updateVolume() {
analyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
const volume = average / 255; // 0-1
// Update UI with volume
updateVolumeUI(volume);
requestAnimationFrame(updateVolume);
}
updateVolume();
}
});
}
Data Channel Communication
Send Text Messages
async function sendTextMessage(room: Room, message: string) {
const data = {
type: 'user_text',
data: message,
};
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
await room.localParticipant.publishData(encodedData, {
reliable: true, // Guaranteed delivery
});
}
Receive Data Messages
room.on(RoomEvent.DataReceived, (payload: Uint8Array, participant) => {
const decoder = new TextDecoder();
const jsonString = decoder.decode(payload);
try {
const message = JSON.parse(jsonString);
switch (message.type) {
case 'transcript_chunk':
handleTranscript(message.data);
break;
case 'agent_status':
handleAgentStatus(message.data);
break;
case 'todo_list':
handleTodoList(message.data);
break;
}
} catch (error) {
console.error('Failed to parse data message:', error);
}
});
Message Types
Transcript Chunk
interface TranscriptChunk {
type: 'transcript_chunk';
data: {
text: string;
is_final: boolean;
confidence: number;
timestamp: string;
participant_id: string;
chunk_id: string;
transcript_id: string;
};
}
Agent Status
interface AgentStatus {
type: 'agent_status';
data: {
status: 'listening' | 'thinking' | 'speaking';
message?: string;
};
}
Todo List
interface TodoList {
type: 'todo_list';
data: {
items: Array<{
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed';
required: boolean;
}>;
timestamp: string;
};
}
React Integration
Custom Hook
import { useState, useEffect, useCallback } from 'react';
import { Room, RoomEvent, Track } from 'livekit-client';
interface UseSessionOptions {
sessionId: string;
userId: string;
userName: string;
onTranscript?: (data: any) => void;
onTodoList?: (data: any) => void;
onAgentStatus?: (data: any) => void;
}
export function useSession(options: UseSessionOptions) {
const [room, setRoom] = useState<Room | null>(null);
const [connected, setConnected] = useState(false);
const [participants, setParticipants] = useState<string[]>([]);
const [micEnabled, setMicEnabled] = useState(false);
useEffect(() => {
let currentRoom: Room | null = null;
async function connect() {
const response = await fetch(
`/api/sessions/${options.sessionId}/joinToken`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identity: options.userId,
name: options.userName,
}),
}
);
const { token, serverUrl } = await response.json();
currentRoom = new Room();
currentRoom.on(RoomEvent.Connected, () => setConnected(true));
currentRoom.on(RoomEvent.Disconnected, () => setConnected(false));
currentRoom.on(RoomEvent.ParticipantConnected, (participant) => {
setParticipants((prev) => [...prev, participant.identity]);
});
currentRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
setParticipants((prev) =>
prev.filter((id) => id !== participant.identity)
);
});
currentRoom.on(RoomEvent.DataReceived, (payload) => {
const decoder = new TextDecoder();
const message = JSON.parse(decoder.decode(payload));
if (message.type === 'transcript_chunk') {
options.onTranscript?.(message.data);
} else if (message.type === 'todo_list') {
options.onTodoList?.(message.data);
} else if (message.type === 'agent_status') {
options.onAgentStatus?.(message.data);
}
});
await currentRoom.connect(serverUrl, token);
await currentRoom.localParticipant.setMicrophoneEnabled(true);
setMicEnabled(true);
setRoom(currentRoom);
}
connect();
return () => {
currentRoom?.disconnect();
};
}, [options.sessionId, options.userId, options.userName]);
const sendMessage = useCallback(
async (text: string) => {
if (!room) return;
const data = { type: 'user_text', data: text };
const encoder = new TextEncoder();
await room.localParticipant.publishData(
encoder.encode(JSON.stringify(data)),
{ reliable: true }
);
},
[room]
);
const toggleMicrophone = useCallback(async () => {
if (!room) return;
const newState = !micEnabled;
await room.localParticipant.setMicrophoneEnabled(newState);
setMicEnabled(newState);
}, [room, micEnabled]);
return {
connected,
participants,
micEnabled,
sendMessage,
toggleMicrophone,
};
}
Component Example
function SessionView({ sessionId }: { sessionId: string }) {
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState('');
const { connected, micEnabled, sendMessage, toggleMicrophone } = useSession({
sessionId,
userId: 'user-123',
userName: 'John Doe',
onTranscript: (data) => {
if (data.is_final) {
setMessages((prev) => [...prev, data.text]);
}
},
});
const handleSend = () => {
if (input.trim()) {
sendMessage(input);
setInput('');
}
};
return (
<div>
<div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
<button onClick={toggleMicrophone}>
{micEnabled ? 'Mute' : 'Unmute'}
</button>
<div>
{messages.map((msg, i) => (
<div key={i}>{msg}</div>
))}
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
/>
<button onClick={handleSend}>Send</button>
</div>
);
}
Troubleshooting
Connection Issues
room.on(RoomEvent.ConnectionStateChanged, (state) => {
console.log('Connection state:', state);
});
room.on(RoomEvent.Reconnecting, () => {
console.log('Reconnecting...');
});
room.on(RoomEvent.Reconnected, () => {
console.log('Reconnected!');
});
Microphone Permission Denied
async function requestMicrophonePermission() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
if (error.name === 'NotAllowedError') {
alert('Microphone permission denied');
}
return false;
}
}
Audio Not Playing
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
const element = track.attach();
// Handle autoplay restrictions
element.play().catch((error) => {
console.log('Autoplay prevented:', error);
// Show "Click to enable audio" button
});
document.body.appendChild(element);
}
});
Best Practices
- Always disconnect on unmount to free resources
- Request microphone permission early for better UX
- Use reliable: true for important messages (transcripts, commands)
- Handle reconnection gracefully with loading states
- Provide visual feedback for connection status
- Implement error boundaries for LiveKit errors