Skip to main content

Build Your First Voice Assistant

This tutorial walks you through building a complete voice assistant from scratch. By the end, you'll have a working application where you can speak to an AI and hear it respond.

What you'll learn:

  • Set up a real-time voice server with LLMRTC
  • Build a React frontend that captures audio and plays responses
  • Handle the complete voice conversation lifecycle

Time: ~20 minutes


What You'll Build​

The flow works like this:

  1. You speak → Microphone captures audio
  2. Server transcribes → OpenAI Whisper converts speech to text
  3. AI thinks → Claude generates a response
  4. Server synthesizes → OpenAI TTS converts text to speech
  5. You hear → Audio plays through your speakers

All of this happens in real-time with streaming, so responses feel natural and conversational.


Prerequisites​

Before starting, make sure you have:

Required Software​

  • Node.js 20+ - Check with node --version
  • FFmpeg - Required for streaming TTS audio processing
# Install FFmpeg
# macOS
brew install ffmpeg

# Ubuntu/Debian
sudo apt-get install ffmpeg

# Windows (with chocolatey)
choco install ffmpeg

API Keys​

You'll need API keys from two providers:

  1. OpenAI API Key - For speech-to-text and text-to-speech

  2. Anthropic API Key - For the Claude LLM

Browser​

A modern browser with microphone support:

  • Chrome (recommended)
  • Firefox
  • Safari
  • Edge

Project Setup​

Let's create the project structure. Create a new directory and set up the files:

mkdir voice-assistant
cd voice-assistant

Your final structure will look like this:

voice-assistant/
├── server.ts # Backend server
├── package.json # Root dependencies
├── tsconfig.json # TypeScript config
├── .env # API keys (don't commit!)
└── client/ # React frontend
├── index.html
├── package.json
├── vite.config.ts
└── src/
├── main.tsx
└── App.tsx

File 1: Root package.json​

Create package.json in the root directory:

{
"name": "voice-assistant",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "tsx server.ts",
"dev": "concurrently \"tsx watch server.ts\" \"npm run dev --prefix client\""
},
"dependencies": {
"@llmrtc/llmrtc-backend": "^1.3.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"concurrently": "^9.0.0"
}
}

What each dependency does:

  • @llmrtc/llmrtc-backend - The LLMRTC server with all providers
  • dotenv - Loads environment variables from .env
  • tsx - Runs TypeScript directly without compilation
  • concurrently - Runs server and client simultaneously

File 2: tsconfig.json​

Create tsconfig.json:

{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["server.ts"]
}

File 3: .env​

Create .env with your API keys:

# OpenAI - Used for speech-to-text (Whisper) and text-to-speech
OPENAI_API_KEY=sk-...

# Anthropic - Used for the Claude LLM
ANTHROPIC_API_KEY=sk-ant-...
Keep Your Keys Secret

Never commit .env to version control. Add it to .gitignore:

echo ".env" >> .gitignore

File 4: Client Setup​

Create the client directory and files:

mkdir -p client/src

Create client/package.json:

{
"name": "voice-assistant-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@llmrtc/llmrtc-web-client": "^1.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.0",
"vite": "^5.4.10"
}
}

Create client/vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});

Create client/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice Assistant</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#root {
width: 100%;
max-width: 600px;
padding: 2rem;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Build the Backend​

Now let's build the server. This is where all the AI magic happens.

Create server.ts:

/**
* Voice Assistant Server
*
* This server handles:
* - WebRTC connections from the browser
* - Speech-to-text via OpenAI Whisper
* - AI responses via Claude
* - Text-to-speech via OpenAI TTS
*/

// Load environment variables first
import { config } from 'dotenv';
config();

// Import the LLMRTC server and providers
import {
LLMRTCServer,
AnthropicLLMProvider,
OpenAIWhisperProvider,
OpenAITTSProvider
} from '@llmrtc/llmrtc-backend';

// Validate required environment variables
if (!process.env.OPENAI_API_KEY) {
console.error('Error: OPENAI_API_KEY is required in .env');
process.exit(1);
}
if (!process.env.ANTHROPIC_API_KEY) {
console.error('Error: ANTHROPIC_API_KEY is required in .env');
process.exit(1);
}

// Create the server with providers
const server = new LLMRTCServer({
// Configure the three AI providers
providers: {
// LLM: Claude for generating responses
llm: new AnthropicLLMProvider({
apiKey: process.env.ANTHROPIC_API_KEY,
model: 'claude-sonnet-5'
}),

// STT: OpenAI Whisper for speech-to-text
stt: new OpenAIWhisperProvider({
apiKey: process.env.OPENAI_API_KEY
}),

// TTS: OpenAI for text-to-speech
tts: new OpenAITTSProvider({
apiKey: process.env.OPENAI_API_KEY,
voice: 'nova' // Options: alloy, ash, coral, echo, fable, nova, onyx, sage, shimmer (+ ballad, verse on gpt-4o-mini-tts)
})
},

// Server configuration
port: 8787,
streamingTTS: true, // Stream audio for lower latency

// System prompt defines the assistant's personality
systemPrompt: `You are a helpful voice assistant. Keep your responses concise
and conversational - typically 1-2 sentences. Speak naturally as if having
a real conversation. Avoid using markdown, bullet points, or other formatting
that doesn't work well when spoken aloud.`
});

// Log when server starts
server.on('listening', ({ host, port }) => {
console.log('');
console.log(' Voice Assistant Server');
console.log(' ======================');
console.log(` Server: http://${host}:${port}`);
console.log(` Client: http://localhost:5173`);
console.log('');
console.log(' Ready for connections!');
console.log('');
});

// Log client connections
server.on('connection', ({ id }) => {
console.log(`[${new Date().toLocaleTimeString()}] Client connected: ${id}`);
});

// Log client disconnections
server.on('disconnect', ({ id }) => {
console.log(`[${new Date().toLocaleTimeString()}] Client disconnected: ${id}`);
});

// Log errors
server.on('error', (error) => {
console.error('[Server Error]', error.message);
});

// Start the server
await server.start();

Key parts explained:

  1. Providers - We configure three AI services:

    • AnthropicLLMProvider - Claude generates intelligent responses
    • OpenAIWhisperProvider - Converts your speech to text
    • OpenAITTSProvider - Converts AI text back to speech
  2. streamingTTS - When true, audio streams to the browser as it's generated, reducing latency

  3. systemPrompt - Defines how the AI should behave and respond

Give your assistant a voice persona

The system prompt shapes what the assistant says; with the instructable gpt-4o-mini-tts model you can also shape how it sounds:

tts: new OpenAITTSProvider({
apiKey: process.env.OPENAI_API_KEY,
model: 'gpt-4o-mini-tts',
voice: 'coral',
instructions: 'Warm and upbeat, like a friendly concierge. Keep a natural conversational pace.'
})

Build the Frontend​

Now let's create the React frontend that captures your voice and displays the conversation.

Create client/src/main.tsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

Create client/src/App.tsx:

/**
* Voice Assistant Client
*
* This React component handles:
* - Connecting to the LLMRTC server
* - Capturing microphone audio
* - Displaying conversation transcript
* - Playing TTS audio responses
*/

import { useState, useEffect, useRef } from 'react';
import { LLMRTCWebClient } from '@llmrtc/llmrtc-web-client';

// Status states for the conversation
type Status = 'disconnected' | 'connecting' | 'idle' | 'listening' | 'thinking' | 'speaking';

export default function App() {
// State for UI
const [status, setStatus] = useState<Status>('disconnected');
const [transcript, setTranscript] = useState('');
const [response, setResponse] = useState('');
const [error, setError] = useState<string | null>(null);

// Refs for client and audio element
const clientRef = useRef<LLMRTCWebClient | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);

useEffect(() => {
// Create the LLMRTC client
const client = new LLMRTCWebClient({
signallingUrl: 'ws://localhost:8787'
});
clientRef.current = client;

// ==========================================
// Connection Events
// ==========================================

client.on('stateChange', (state) => {
console.log('[Client] State:', state);
if (state === 'connected') {
setStatus('idle');
} else if (state === 'connecting') {
setStatus('connecting');
} else if (state === 'disconnected' || state === 'failed') {
setStatus('disconnected');
}
});

// ==========================================
// Voice Activity Detection (VAD) Events
// ==========================================

// User started speaking
client.on('speechStart', () => {
console.log('[Client] Speech started');
setStatus('listening');
setResponse(''); // Clear previous response
});

// User stopped speaking
client.on('speechEnd', () => {
console.log('[Client] Speech ended');
setStatus('thinking');
});

// ==========================================
// Transcription Events
// ==========================================

// Received transcript of what user said
client.on('transcript', (text) => {
console.log('[Client] Transcript:', text);
setTranscript(text);
});

// ==========================================
// LLM Response Events
// ==========================================

// Receiving streamed LLM response
client.on('llmChunk', (chunk) => {
setResponse((prev) => prev + chunk);
});

// ==========================================
// TTS Audio Events
// ==========================================

// Received audio track for playback
client.on('ttsTrack', (stream) => {
console.log('[Client] TTS track received');
if (!audioRef.current) {
audioRef.current = new Audio();
}
audioRef.current.srcObject = stream;
audioRef.current.play().catch((err) => {
// Browser may block autoplay - user needs to interact first
console.warn('Autoplay blocked:', err.message);
setError('Click anywhere to enable audio playback');
});
});

// TTS playback started
client.on('ttsStart', () => {
console.log('[Client] TTS started');
setStatus('speaking');
});

// TTS playback completed
client.on('ttsComplete', () => {
console.log('[Client] TTS complete');
setStatus('idle');
});

// User interrupted (barge-in)
client.on('ttsCancelled', () => {
console.log('[Client] TTS cancelled (barge-in)');
setStatus('listening');
});

// ==========================================
// Error Handling
// ==========================================

client.on('error', (err) => {
console.error('[Client] Error:', err);
setError(err.message);
// Clear error after 5 seconds
setTimeout(() => setError(null), 5000);
});

// ==========================================
// Connect and Start
// ==========================================

async function connect() {
try {
// Connect to server
await client.start();

// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});

// Share audio with server
await client.shareAudio(stream);

setStatus('idle');
console.log('[Client] Connected and ready');
} catch (err) {
console.error('[Client] Connection failed:', err);
setError(err instanceof Error ? err.message : 'Connection failed');
setStatus('disconnected');
}
}

connect();

// Cleanup on unmount
return () => {
client.close();
if (audioRef.current) {
audioRef.current.srcObject = null;
}
};
}, []);

// Status indicator colors
const statusColors: Record<Status, string> = {
disconnected: '#666',
connecting: '#f39c12',
idle: '#27ae60',
listening: '#3498db',
thinking: '#9b59b6',
speaking: '#e74c3c'
};

// Status indicator labels
const statusLabels: Record<Status, string> = {
disconnected: 'Disconnected',
connecting: 'Connecting...',
idle: 'Ready - Start speaking',
listening: 'Listening...',
thinking: 'Thinking...',
speaking: 'Speaking...'
};

return (
<div style={{ textAlign: 'center' }}>
<h1 style={{ marginBottom: '2rem', fontSize: '1.75rem', fontWeight: 600 }}>
Voice Assistant
</h1>

{/* Status Indicator */}
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.5rem',
borderRadius: '2rem',
background: 'rgba(255,255,255,0.1)',
marginBottom: '2rem'
}}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: statusColors[status],
boxShadow: `0 0 10px ${statusColors[status]}`,
animation: status !== 'disconnected' && status !== 'idle'
? 'pulse 1.5s infinite'
: 'none'
}} />
<span style={{ fontSize: '0.95rem' }}>{statusLabels[status]}</span>
</div>

{/* Transcript Panel - What you said */}
<div style={{
background: 'rgba(255,255,255,0.05)',
borderRadius: '1rem',
padding: '1.5rem',
marginBottom: '1rem',
minHeight: '100px',
textAlign: 'left'
}}>
<div style={{
fontSize: '0.75rem',
color: '#888',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
You said
</div>
<div style={{ fontSize: '1.1rem', lineHeight: 1.5 }}>
{transcript || (
<span style={{ color: '#555', fontStyle: 'italic' }}>
Start speaking to see your transcript...
</span>
)}
</div>
</div>

{/* Response Panel - What AI said */}
<div style={{
background: 'rgba(255,255,255,0.05)',
borderRadius: '1rem',
padding: '1.5rem',
minHeight: '150px',
textAlign: 'left'
}}>
<div style={{
fontSize: '0.75rem',
color: '#888',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Assistant
</div>
<div style={{ fontSize: '1.1rem', lineHeight: 1.6 }}>
{response || (
<span style={{ color: '#555', fontStyle: 'italic' }}>
The assistant will respond here...
</span>
)}
</div>
</div>

{/* Error Message */}
{error && (
<div style={{
marginTop: '1rem',
padding: '0.75rem 1rem',
background: 'rgba(231, 76, 60, 0.2)',
border: '1px solid rgba(231, 76, 60, 0.3)',
borderRadius: '0.5rem',
fontSize: '0.9rem',
color: '#e74c3c'
}}>
{error}
</div>
)}

{/* Pulse animation for status indicator */}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.95); }
}
`}</style>
</div>
);
}

Key parts explained:

  1. Connection Setup - The LLMRTCWebClient connects to your server via WebSocket/WebRTC

  2. Event Handlers - The client emits events for each stage:

    • speechStart / speechEnd - Voice activity detection
    • transcript - What you said
    • llmChunk - Streaming AI response
    • ttsTrack - Audio to play
  3. Audio Handling - The ttsTrack event provides a MediaStream that we attach to an Audio element

  4. Status States - The UI shows where you are in the conversation:

    • idle - Ready for input
    • listening - You're speaking
    • thinking - AI is generating
    • speaking - Playing response

Install Dependencies and Run​

Now let's install everything and start the application.

Install Dependencies​

# Install root dependencies
npm install

# Install client dependencies
cd client && npm install && cd ..

Start the Application​

# Run both server and client
npm run dev

You should see output like:

  Voice Assistant Server
======================
Server: http://127.0.0.1:8787
Client: http://localhost:5173

Ready for connections!

Test It​

  1. Open http://localhost:5173 in your browser
  2. Allow microphone access when prompted
  3. Start speaking - try "Hello, what can you help me with?"
  4. Watch the status indicator change: listening → thinking → speaking
  5. Hear the response through your speakers

Troubleshooting​

"NotAllowedError: Permission denied"​

Problem: Browser blocked microphone access.

Solution:

  1. Click the lock/camera icon in the address bar
  2. Set microphone permission to "Allow"
  3. Refresh the page

"WebSocket connection failed"​

Problem: Client can't connect to server.

Solution:

  1. Check the server is running (npm run dev)
  2. Verify the URL matches: ws://localhost:8787
  3. Check no firewall is blocking the port

No Audio Playback​

Problem: TTS audio doesn't play.

Solutions:

  1. Click anywhere on the page first (browser autoplay policy)
  2. Check your system volume isn't muted
  3. Look for errors in browser console (F12)

"Error: OPENAI_API_KEY is required"​

Problem: Environment variables not loaded.

Solution:

  1. Make sure .env exists in the root directory
  2. Check API keys are correct (no extra spaces)
  3. Restart the server after changing .env

High Latency / Slow Responses​

Problem: Long delay between speaking and response.

Solutions:

  1. Ensure streamingTTS: true is set in server.ts
  2. Use faster models (Claude Haiku 4.5, gpt-5.6-luna, or gpt-5.6-terra)
  3. Keep system prompt concise
  4. Check your internet connection

Next Steps​

Congratulations! You've built a complete voice assistant. Here's where to go next:

Add Tool Calling​

Give your assistant the ability to do things like check the weather, look up information, or control devices. Tool calling requires Playbook mode for the voice pipeline.

See these guides:

Add Multi-Stage Conversations​

Use Playbooks to create structured conversations with stages and transitions - perfect for customer support, booking systems, or guided workflows.

Deploy to Production​

See the Deployment Guide to:

  • Add TURN servers for NAT traversal
  • Configure for production environments
  • Set up monitoring and logging

Add Vision Capabilities​

Let your assistant see! Check out Video and Vision to add camera or screen sharing.

Use Different Providers​

Mix and match providers for your use case: