Test and validate Twilio Voice API integration with ElevenLabs ConversationRelay for outbound calling...
Comprehensive testing framework for Twilio Voice API + ElevenLabs ConversationRelay integration on the Next Level Real Estate platform. This skill provides systematic testing procedures, quality validation, and troubleshooting guidance.
Invoke this skill when you need to:
โโโโโโโโโโโโโโโโโโโ
โ Next Level RE โ
โ Application โ
โโโโโโโโโโฌโโโโโโโโโ
โ
โ 1. Initiate call with
โ lead context
โผ
โโโโโโโโโโโโโโโโโโโ
โ Twilio Voice โโโโโโ Phone Number
โ API โ
โโโโโโโโโโฌโโโโโโโโโ
โ
โ 2. ConversationRelay
โ webhook
โผ
โโโโโโโโโโโโโโโโโโโ
โ ElevenLabs โ
โ Conversational โ
โ AI โ
โโโโโโโโโโฌโโโโโโโโโ
โ
โ 3. Bidirectional
โ audio stream
โผ
โโโโโโโโโโโโโโโโโโโ
โ Lead's Phone โ
โโโโโโโโโโโโโโโโโโโ
# 1. Twilio Account Requirements
- [ ] Active Twilio account
- [ ] Account SID
- [ ] Auth Token
- [ ] At least one phone number (verified or purchased)
- [ ] Voice API enabled
# 2. ElevenLabs Requirements
- [ ] ElevenLabs API key
- [ ] At least one conversational agent created
- [ ] Agent ID noted
# 3. Environment Configuration
- [ ] TWILIO_ACCOUNT_SID set
- [ ] TWILIO_AUTH_TOKEN set
- [ ] TWILIO_PHONE_NUMBER set
- [ ] ELEVENLABS_API_KEY set
- [ ] Webhook endpoint accessible (public URL)
Create .env file with required credentials:
# Twilio Configuration
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
TWILIO_PHONE_NUMBER=+1234567890
# ElevenLabs Configuration
ELEVENLABS_API_KEY=your_elevenlabs_api_key
ELEVENLABS_AGENT_ID=agent_xxxxxxxx
# Webhook Configuration
WEBHOOK_BASE_URL=https://your-domain.com
WEBHOOK_SECRET=your_webhook_secret
# Testing
TEST_PHONE_NUMBER=+1234567890 # Your test number
cd services/calling-service
npm install twilio
Create services/calling-service/src/clients/twilio.ts:
import twilio from 'twilio'
export interface TwilioConfig {
accountSid: string
authToken: string
phoneNumber: string
}
export interface CallConfig {
to: string
conversationId: string
webhookUrl: string
}
export function createTwilioClient(config: TwilioConfig) {
const client = twilio(config.accountSid, config.authToken)
return {
async initiateCall(callConfig: CallConfig) {
const call = await client.calls.create({
from: config.phoneNumber,
to: callConfig.to,
url: `${callConfig.webhookUrl}/twiml/${callConfig.conversationId}`,
statusCallback: `${callConfig.webhookUrl}/status/${callConfig.conversationId}`,
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
record: true, // Record for TCPA compliance
})
return {
callSid: call.sid,
status: call.status,
to: call.to,
from: call.from,
}
},
async getCallStatus(callSid: string) {
const call = await client.calls(callSid).fetch()
return {
sid: call.sid,
status: call.status,
duration: call.duration,
startTime: call.startTime,
endTime: call.endTime,
}
},
async hangupCall(callSid: string) {
await client.calls(callSid).update({ status: 'completed' })
},
}
}
Create services/calling-service/src/routes/twiml.ts:
import { Router } from 'express'
import twilio from 'twilio'
const router = Router()
const VoiceResponse = twilio.twiml.VoiceResponse
router.post('/twiml/:conversationId', async (req, res) => {
const { conversationId } = req.params
// Get conversation details from ElevenLabs
const conversation = await getElevenLabsConversation(conversationId)
const twiml = new VoiceResponse()
// Optional: Add recording consent message
if (conversation.recordingConsent) {
twiml.say({
voice: 'alice',
language: 'en-US'
}, 'This call may be recorded for quality and training purposes.')
}
// Connect to ElevenLabs ConversationRelay
const connect = twiml.connect()
connect.stream({
url: `wss://api.elevenlabs.io/v1/convai/${conversationId}/stream`,
parameters: {
apiKey: process.env.ELEVENLABS_API_KEY,
agentId: conversation.agentId,
}
})
res.type('text/xml')
res.send(twiml.toString())
})
router.post('/status/:conversationId', async (req, res) => {
const { conversationId } = req.params
const { CallStatus, CallDuration, RecordingUrl } = req.body
console.log(`Call ${conversationId}: ${CallStatus}`)
// Update conversation status in your database
await updateConversationStatus(conversationId, {
status: CallStatus,
duration: CallDuration,
recordingUrl: RecordingUrl,
})
res.sendStatus(200)
})
export default router
# Install ngrok for local testing
npm install -g ngrok
# Start your calling service
npm run dev
# In another terminal, expose local server
ngrok http 3000
# Update WEBHOOK_BASE_URL with ngrok URL
# Example: https://abc123.ngrok.io
Objective: Verify Twilio can reach your webhook endpoint
# Test webhook endpoint is accessible
curl -X POST https://your-domain.com/twiml/test-123 \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "From=+1234567890&To=+0987654321"
# Expected: 200 OK with valid TwiML response
Success Criteria:
Objective: Verify agent is configured and accessible
# Use MCP tool to check agent
Use mcp__elevenlabs__elevenlabs_get_agent with:
{
"agentId": "your_agent_id"
}
Success Criteria:
Objective: Execute complete call flow end-to-end
// Create test script
async function testOutboundCall() {
// 1. Start ElevenLabs conversation
const conversation = await startElevenLabsConversation({
agentId: process.env.ELEVENLABS_AGENT_ID,
leadData: {
name: "Test Lead",
phone: process.env.TEST_PHONE_NUMBER,
propertyAddress: "123 Test St"
},
maxDuration: 120 // 2 minutes for test
})
// 2. Initiate Twilio call
const call = await twilioClient.initiateCall({
to: process.env.TEST_PHONE_NUMBER,
conversationId: conversation.conversationId,
webhookUrl: process.env.WEBHOOK_BASE_URL
})
console.log(`Test call initiated:`)
console.log(`- Call SID: ${call.callSid}`)
console.log(`- Conversation ID: ${conversation.conversationId}`)
console.log(`- Status: ${call.status}`)
// 3. Monitor call status
let callStatus = call.status
while (callStatus !== 'completed' && callStatus !== 'failed') {
await sleep(5000) // Wait 5 seconds
const status = await twilioClient.getCallStatus(call.callSid)
callStatus = status.status
console.log(`Call status: ${callStatus}`)
}
// 4. Get conversation details
const conversationDetails = await getElevenLabsConversation(
conversation.conversationId
)
// 5. Report results
console.log(`\nTest Results:`)
console.log(`- Call Status: ${callStatus}`)
console.log(`- Duration: ${conversationDetails.duration}s`)
console.log(`- Sentiment: ${conversationDetails.sentiment?.overall}`)
console.log(`- Transcript lines: ${conversationDetails.transcript?.length}`)
return {
success: callStatus === 'completed',
callSid: call.callSid,
conversationId: conversation.conversationId,
duration: conversationDetails.duration,
transcript: conversationDetails.transcript
}
}
Success Criteria:
Objective: Measure and validate call audio quality
Manual Checklist:
## Audio Quality Checklist
### Agent Audio (What You Hear)
- [ ] Voice is clear and intelligible
- [ ] No robotic or choppy artifacts
- [ ] Volume is appropriate (not too loud/quiet)
- [ ] No echo or feedback
- [ ] Natural pacing and rhythm
### User Audio (What Agent Hears)
- [ ] Agent responds to your speech accurately
- [ ] No repeated requests for clarification
- [ ] Agent picks up on tone/sentiment
- [ ] Background noise doesn't confuse agent
### Technical Metrics
- [ ] Response latency <200ms
- [ ] No audio dropouts >1 second
- [ ] Turn-taking feels natural
- [ ] No awkward pauses >3 seconds
Automated Metrics:
interface AudioQualityMetrics {
avgResponseLatency: number // milliseconds
maxResponseLatency: number // milliseconds
audioDropouts: number // count
turnTakingQuality: number // 0-1 score
clarificationRequests: number // count
}
async function measureAudioQuality(conversationId: string) {
const conversation = await getElevenLabsConversation(conversationId)
// Calculate metrics from transcript
const metrics: AudioQualityMetrics = {
avgResponseLatency: calculateAvgLatency(conversation.transcript),
maxResponseLatency: calculateMaxLatency(conversation.transcript),
audioDropouts: countDropouts(conversation.transcript),
turnTakingQuality: scoreTurnTaking(conversation.transcript),
clarificationRequests: countClarifications(conversation.transcript)
}
// Validate against thresholds
const quality = {
excellent: metrics.avgResponseLatency < 150 &&
metrics.audioDropouts === 0 &&
metrics.turnTakingQuality > 0.8,
good: metrics.avgResponseLatency < 250 &&
metrics.audioDropouts < 2 &&
metrics.turnTakingQuality > 0.6,
acceptable: metrics.avgResponseLatency < 400 &&
metrics.audioDropouts < 5 &&
metrics.turnTakingQuality > 0.4
}
return { metrics, quality }
}
Objective: Verify lead data is properly used in conversation
async function testContextInjection() {
const testLeadData = {
name: "John Smith",
phone: "+1234567890",
propertyAddress: "456 Oak Avenue, Austin TX",
estimatedValue: 250000,
motivation: "probate",
timeline: "urgent"
}
// Start conversation with context
const conversation = await startElevenLabsConversation({
agentId: process.env.ELEVENLABS_AGENT_ID,
leadData: testLeadData,
propertyInfo: {
address: testLeadData.propertyAddress,
estimatedValue: testLeadData.estimatedValue,
condition: "needs_repairs"
}
})
// Initiate call
const call = await twilioClient.initiateCall({
to: testLeadData.phone,
conversationId: conversation.conversationId,
webhookUrl: process.env.WEBHOOK_BASE_URL
})
// After call, check transcript for context usage
const details = await getElevenLabsConversation(conversation.conversationId)
const transcript = details.transcript.map(t => t.text).join(' ')
// Verify agent used the context
const contextChecks = {
usedName: transcript.includes(testLeadData.name),
mentionedAddress: transcript.includes('Oak Avenue'),
referencedMotivation: transcript.toLowerCase().includes('probate'),
acknowledgedTimeline: transcript.toLowerCase().includes('urgent') ||
transcript.toLowerCase().includes('soon')
}
console.log('Context Injection Results:')
console.log(`- Used name: ${contextChecks.usedName ? 'โ' : 'โ'}`)
console.log(`- Mentioned address: ${contextChecks.mentionedAddress ? 'โ' : 'โ'}`)
console.log(`- Referenced motivation: ${contextChecks.referencedMotivation ? 'โ' : 'โ'}`)
console.log(`- Acknowledged timeline: ${contextChecks.acknowledgedTimeline ? 'โ' : 'โ'}`)
return contextChecks
}
Success Criteria:
Objective: Verify all TCPA 2025 requirements are met
## TCPA Compliance Checklist
### Pre-Call Requirements
- [ ] Written consent verified before call
- [ ] Lead not on national DNC registry
- [ ] Consent date within validity period
- [ ] Consent source documented
### During Call
- [ ] Recording disclosure at call start (if recording)
- [ ] Agent identifies company name
- [ ] Agent states purpose of call clearly
- [ ] Opt-out option provided if requested
### Post-Call
- [ ] Call details logged (date, time, duration, outcome)
- [ ] Recording URL captured
- [ ] Opt-out requests honored immediately
- [ ] Audit trail maintained
async function testTCPACompliance(conversationId: string) {
const conversation = await getElevenLabsConversation(conversationId)
const transcript = conversation.transcript.map(t => t.text).join(' ').toLowerCase()
const compliance = {
recordingDisclosure: transcript.includes('recorded') ||
transcript.includes('recording'),
companyIdentified: transcript.includes('next level real estate'),
purposeStated: transcript.includes('property') ||
transcript.includes('selling'),
optOutOffered: true // Assumed agent can handle if requested
}
const allCompliant = Object.values(compliance).every(v => v === true)
return {
compliant: allCompliant,
checks: compliance,
issues: Object.entries(compliance)
.filter(([k, v]) => !v)
.map(([k]) => k)
}
}
Objective: Verify system handles concurrent calls
async function loadTest(concurrentCalls: number = 10) {
console.log(`Starting load test with ${concurrentCalls} concurrent calls...`)
const testPromises = []
const results = []
for (let i = 0; i < concurrentCalls; i++) {
const promise = testOutboundCall()
.then(result => {
results.push({ success: true, ...result })
})
.catch(error => {
results.push({ success: false, error: error.message })
})
testPromises.push(promise)
// Stagger start times by 1 second
await sleep(1000)
}
// Wait for all calls to complete
await Promise.all(testPromises)
// Analyze results
const successCount = results.filter(r => r.success).length
const failureCount = results.filter(r => !r.success).length
const successRate = (successCount / concurrentCalls) * 100
console.log(`\nLoad Test Results:`)
console.log(`- Total calls: ${concurrentCalls}`)
console.log(`- Successful: ${successCount}`)
console.log(`- Failed: ${failureCount}`)
console.log(`- Success rate: ${successRate.toFixed(1)}%`)
// Report any failures
if (failureCount > 0) {
console.log(`\nFailures:`)
results.filter(r => !r.success).forEach((r, i) => {
console.log(` ${i + 1}. ${r.error}`)
})
}
return {
totalCalls: concurrentCalls,
successCount,
failureCount,
successRate,
results
}
}
Success Criteria:
interface CallMetrics {
// Connection metrics
initiatedCalls: number
connectedCalls: number
failedCalls: number
connectRate: number // percentage
// Duration metrics
avgDuration: number // seconds
minDuration: number
maxDuration: number
// Quality metrics
avgAudioQuality: number // 0-1 score
avgResponseLatency: number // milliseconds
audioDropoutRate: number // percentage
// Business metrics
conversationCompletionRate: number // percentage
positiveSentimentRate: number // percentage
qualifiedLeadRate: number // percentage
}
async function collectMetrics(timeRange: { start: Date, end: Date }) {
// Get all conversations in time range
const conversations = await listElevenLabsConversations({
startDate: timeRange.start,
endDate: timeRange.end
})
// Calculate metrics
const metrics: CallMetrics = {
initiatedCalls: conversations.length,
connectedCalls: conversations.filter(c => c.status === 'completed').length,
failedCalls: conversations.filter(c => c.status === 'failed').length,
connectRate: 0, // calculated below
avgDuration: average(conversations.map(c => c.duration || 0)),
minDuration: Math.min(...conversations.map(c => c.duration || Infinity)),
maxDuration: Math.max(...conversations.map(c => c.duration || 0)),
avgAudioQuality: average(conversations.map(c => c.audioQuality || 0)),
avgResponseLatency: average(conversations.map(c => c.avgLatency || 0)),
audioDropoutRate: (conversations.filter(c => c.audioDropouts > 0).length / conversations.length) * 100,
conversationCompletionRate: (conversations.filter(c => c.goalReached).length / conversations.length) * 100,
positiveSentimentRate: (conversations.filter(c => c.sentiment?.overall === 'positive').length / conversations.length) * 100,
qualifiedLeadRate: (conversations.filter(c => c.leadQualified).length / conversations.length) * 100
}
metrics.connectRate = (metrics.connectedCalls / metrics.initiatedCalls) * 100
return metrics
}
Symptoms:
Diagnosis Steps:
Solutions:
# Test webhook accessibility
curl https://your-domain.com/twiml/test
# Check Twilio account status
# Log into Twilio Console > Monitor > Debugger
# Verify phone number
curl -X GET "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/IncomingPhoneNumbers.json" \
-u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN"
Symptoms:
Diagnosis Steps:
Solutions:
// Switch to lower latency model
await updateAgent(agentId, {
modelId: "eleven_flash_v2_5", // Fastest model
responseLatency: 75
})
// Reduce concurrent calls if bandwidth limited
maxConcurrentCalls = 5
// Use codec optimization
// In Twilio call config:
{
codec: 'opus', // Better quality than PCMU
jitterBufferSize: 'small'
}
Symptoms:
Diagnosis Steps:
Solutions:
// Ensure context is properly formatted
const context = {
leadData: {
name: "John", // Simple, clear field names
property: "123 Main St"
}
}
// Update system prompt to reference context
systemPrompt += `\n\nIMPORTANT: You will receive lead context including name and property address. Use these naturally in conversation.`
// Test context injection separately
console.log('Context sent:', JSON.stringify(context, null, 2))
Symptoms:
Diagnosis Steps:
Solutions:
// Ensure compliance settings
const agent = await createAgent({
// ...other config
tcpaCompliance: true,
recordingConsent: true,
greeting: "Hi [name], this is [agent name] calling from Next Level Real Estate. This call may be recorded. [continue...]"
})
// Add opt-out handling to system prompt
systemPrompt += `\n\nIF the person asks to be removed from the call list, apologize politely, confirm their request, and end the call immediately.`
## Pre-Production Testing Checklist
### Infrastructure
- [ ] Twilio account configured
- [ ] Phone numbers purchased/verified
- [ ] Webhook endpoints accessible
- [ ] ElevenLabs agents created
- [ ] Environment variables set
### Integration
- [ ] Webhook connectivity tested
- [ ] TwiML response validated
- [ ] ConversationRelay configured
- [ ] Status callbacks working
### Functionality
- [ ] Single call successful
- [ ] Audio quality acceptable
- [ ] Context injection working
- [ ] Agent responds appropriately
- [ ] Call completes gracefully
### Quality
- [ ] Response latency <200ms
- [ ] No audio dropouts
- [ ] Natural turn-taking
- [ ] Clear voice quality
### Compliance
- [ ] Recording disclosure present
- [ ] Company identified
- [ ] Purpose stated
- [ ] Opt-out option available
- [ ] Consent verified
### Performance
- [ ] Load test passed (10+ concurrent)
- [ ] Success rate >95%
- [ ] No system crashes
- [ ] Metrics collected
### Documentation
- [ ] Test results documented
- [ ] Issues logged
- [ ] Troubleshooting guide updated
- [ ] Team trained
services/calling-service/src/clients/twilio.tsservices/calling-service/src/routes/twiml.tsservices/calling-service/tests/Remember: Thorough testing prevents costly production issues. Test every scenario, document every issue, and always prioritize call quality and compliance.