Why Real-Time Voice Agents with Claude?
Hey there, Claude enthusiasts! Imagine picking up the phone and chatting with an AI that feels eerily human—quick responses, handles interruptions gracefully, and powered by the brilliant reasoning of Claude 3.5 Sonnet. That's the magic of real-time voice agents.
Traditional voice bots? Clunky. They wait for you to finish speaking, process in batch, and spit out a response after an awkward pause. With streaming WebSockets, STT, Claude's live streaming API, and TTS, we cut latency to under 500ms end-to-end. Perfect for customer support, virtual assistants, or even role-playing games.
In this guide, we'll build one from scratch using Twilio for telephony, Deepgram for speech-to-text, Anthropic Claude API for smarts, and ElevenLabs for voice synthesis. All streamed via WebSockets for real-time magic.
We'll compare batch vs. streaming approaches along the way, with full Node.js code you can copy-paste.
Architecture: How It All Fits Together
Here's the flow:
- Caller dials Twilio number → Twilio connects call and starts Media Stream via WebSocket to your server.
- Your server receives raw audio packets → Streams to Deepgram for real-time STT → Partial transcripts arrive every 200ms.
- Transcripts fed to Claude incrementally → Claude streams tokens back via its
/v1/messagesstreaming endpoint. - Claude response → ElevenLabs TTS → Audio chunks streamed back through Twilio WebSocket to caller.
- Interruptions? Detect user barge-in via audio energy and flush Claude context.
(Pro tip: Use Draw.io for your own diagrams)
Batch vs. Streaming Comparison:
| Aspect | Batch Processing | Streaming (Our Setup) |
|---|---|---|
| Latency | 2-5s per turn | <1s end-to-end |
| Naturalness | Robotic pauses | Human-like flow |
| Cost | Lower compute | Higher (but Claude's cheap) |
| Complexity | Easy | Medium (worth it!) |
Claude shines here: Its 200k token context holds full convo history, and streaming feels snappier than GPT-4o thanks to optimized tokenization.
Prerequisites
- Node.js 20+
- Accounts: Twilio, Deepgram (free tier rocks), Anthropic API key, ElevenLabs
- ngrok for local testing (Twilio needs public URL)
Install deps:
npm init -y
npm i express ws @anthropic-ai/sdk deepgram-sdk elevenlabs-node twilio
Step 1: Twilio Setup
- Buy a phone number in Twilio Console (Voice > Phone Numbers).
- Create a TwiML Bin or webhook with this XML:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Start>
<Stream url="wss://your-ngrok-url.com/audio" />
</Start>
<Say>Hi! Chat with Claude now.</Say>
</Response>
Configure your number's webhook to point to a /voice endpoint returning this TwiML.
Pro Tip: Use Twilio's Programmable Voice webhook for dynamic TwiML.
Step 2: WebSocket Server for Audio Streams
Fire up an Express server handling Twilio Media Streams.
// server.js
const express = require('express');
const WebSocket = require('ws');
const { Anthropic } = require('@anthropic-ai/sdk');
const { createClient } = require('@deepgram/sdk');
const ElevenLabs = require('elevenlabs-node');
const app = express();
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const deepgram = createClient(process.env.DEEPGRAM_KEY);
const elevenlabs = new ElevenLabs({ apiKey: process.env.ELEVENLABS_KEY });
app.use(express.static('public'));
// WebSocket server for Twilio
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => {
const callSid = req.url.split('/')[2]; // Extract from Twilio path
let conversation = [{ role: 'user', content: 'You are a helpful assistant.' }];
let buffer = '';
ws.on('message', async (data) => {
const message = JSON.parse(data);
if (message.event === 'media') {
// Stream audio to Deepgram
const transcript = await deepgram.transcription.live({
punctuate: true,
interim_results: true,
}, {
model: 'nova-2',
language: 'en',
smart_format: true,
}).send(message.media.payload);
if (transcript && transcript.channel.alternatives[0].transcript) {
buffer += transcript.channel.alternatives[0].transcript;
if (transcript.channel.alternatives[0].is_final) {
// Final transcript: send to Claude
conversation.push({ role: 'user', content: buffer });
buffer = '';
const stream = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: conversation,
stream: true,
});
let responseText = '';
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta') {
responseText += chunk.delta.text;
// Stream partial to TTS
const audioChunk = await elevenlabs.textToSpeech({
text: responseText.slice(-50), // Last chunk
voice: 'Rachel', // Natural voice
model: 'eleven_monolingual_v1',
stream: true,
});
// Send audio back via Twilio WS
ws.send(JSON.stringify({
event: 'media',
streamSid: message.streamSid,
media: { payload: audioChunk.toString('base64') },
}));
}
}
conversation.push({ role: 'assistant', content: responseText });
}
}
}
});
ws.on('close', () => {
console.log('Call ended');
});
});
app.post('/voice', (req, res) => {
// Return TwiML here
res.type('text/xml');
res.send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Start><Stream url="wss://${req.headers.host}/ws/${callSid}" /></Start>
<Say>Welcome to Claude Voice Agent!</Say>
</Response>`);
});
const server = app.listen(3000, () => console.log('Server on 3000'));
Key Notes:
- This is simplified—add VAD (voice activity detection) for barge-in using
message.media.payloadenergy levels. - Deepgram's Live Transcription SDK handles real-time STT perfectly (~300ms latency).
- Claude streaming via Anthropic SDK: chunks arrive every ~200ms.
Step 3: Enhancing with Interruptions & Context
Claude's long context is gold, but for voice:
- Flush on barge-in: If new audio > threshold during Claude response, send
/abortto stream and restart. - System prompt: Tune for domain, e.g., "You are a sales agent for SaaS tools."
Updated code snippet for interruption:
// In message handler
if (message.event === 'media' && isSpeaking) { // VAD check
// Interrupt Claude stream
claudeStream.controller.abort();
isSpeaking = true;
}
Step 4: TTS Streaming with ElevenLabs
ElevenLabs supports chunked streaming—feed partial Claude text for progressive audio.
Latency Comparison:
| Provider | Streaming Latency | Voice Quality | Claude Integration |
|---|---|---|---|
| ElevenLabs | 250ms | 🎤 Excellent | Native Node SDK |
| PlayHT | 400ms | Good | API only |
| Google TTS | 800ms | Natural | Higher cost |
Deployment & Scaling
- Deploy to Railway, Vercel, or Fly.io.
- Use ngrok:
ngrok http 3000→ Update Twilio webhook. - Scale: Redis for multi-call context, PM2 for clustering.
Costs (per hour call):
- Twilio: $0.0085/min
- Deepgram: $0.0043/min
- Claude: ~$0.003 (10k tokens)
- ElevenLabs: $0.18/1k chars Total: <$0.05/min
Testing Your Agent
- Call your Twilio #.
- Say: "What's the best way to prompt Claude for code?"
- Marvel at the fluid response!
Debug: Twilio Inspector for WS traces, Deepgram console for transcripts.
Limitations & Comparisons
- Claude vs. GPT-4o: Claude cheaper ($3/1M input vs. $5), better reasoning, but GPT has native voice mode (Realtime API). Claude wins on cost/complexity for custom stacks.
- Edge cases: Heavy accents? Deepgram Nova-2 handles 30+ langs. Noisy? Add noise suppression.
- Enterprise: Add auth, logging via MCP or custom agents.
Next Steps
- Add RAG: Pinecone vector DB for knowledge base.
- Multi-turn tools: Call Claude Functions mid-convo.
- Integrate with n8n for workflows.
Fork this on GitHub, tweak, and share your builds in comments! Questions? Drop 'em below.
Word count: ~1450
Stay ahead of the AI curve
The most important updates, news, and content — delivered in one weekly newsletter.