ElevenLabs Cloud Voice Agent with Asterisk SIP Integration
Cloud-Hosted Conversational AI with Custom Webhook Tools, SIP Trunk Routing, and Dynamic Call Context Injection
A production-tested guide to building an AI voice agent that answers live phone calls through your existing Asterisk PBX, using ElevenLabs Conversational AI as the brain and your own webhook server to inject per-call business context.
Table of Contents
- Cloud vs Local Voice Agents
- Architecture Overview
- Prerequisites
- ElevenLabs Account and API Setup
- Creating Webhook Server Tools
- The Webhook Server: DID Context API
- The Webhook Server: Booking API
- Creating the AI Agent via API
- Writing the Agent Prompt
- Asterisk SIP Trunk to ElevenLabs
- Asterisk Dialplan Routing
- Database Schema
- The Complete Setup Script
- Testing the Integration
- Production Deployment Tips
- Monitoring and Logging
- Cost Analysis
- Comparison: Cloud vs Local Voice Agent
- Troubleshooting
1. Cloud vs Local Voice Agents
If you have read Tutorial 03 (Building a Real-Time AI Voice Agent for Asterisk), you know that building a local voice agent means stitching together four separate components: a streaming STT engine (Deepgram), an LLM (Groq), a TTS engine (Cartesia), and a custom Python AudioSocket server to glue them all together. The result is powerful and low-latency (~250ms), but it requires significant development effort, server resources, and ongoing maintenance of multiple API integrations.
The cloud approach flips this entirely. ElevenLabs Conversational AI is an all-in-one platform: STT, LLM, and TTS are bundled into a single managed service. You define an agent with a system prompt, attach webhook-based tools, point a SIP trunk at their endpoint, and calls flow through automatically. No AudioSocket server. No Python daemon. No audio format conversion. No barge-in detection code.
When to Use Cloud (ElevenLabs)
- You need a working voice agent in hours, not weeks
- You want zero infrastructure to maintain for the AI pipeline
- You are routing overflow calls (e.g., when human agents are busy)
- You need SIP-native integration with your existing PBX
- You want built-in voice cloning, multilingual support, and automatic STT/TTS optimization
- Your call volume is moderate (hundreds per day, not thousands)
When to Use Local (AudioSocket + Deepgram + Groq + Cartesia)
- You need absolute lowest latency (sub-200ms is possible locally)
- You want full control over every component
- You are processing thousands of concurrent calls and need to control costs
- You need custom audio processing (recording, mixing, real-time analysis)
- You want to avoid any third-party dependency for the AI pipeline
- You need on-premises deployment for compliance reasons
What This Tutorial Builds
An ElevenLabs-powered voice agent that:
- Receives live inbound calls via SIP from your Asterisk PBX
- Calls your webhook at the start of each call to fetch business context (company name, trade type, pricing)
- Dynamically adjusts its greeting, personality, and pricing based on which phone number the customer dialed
- Follows a strict conversation workflow to collect customer details
- Calls a second webhook to create a booking in your database
- Handles objections, repeat callers, and edge cases naturally
2. Architecture Overview
TELEPHONE NETWORK
|
SIP Trunk (inbound)
|
+-----------v-----------+
| ASTERISK PBX |
| |
| DID arrives |
| ViciDial inbound |
| group processes it |
| |
| If no agents free: |
| overflow to |
| elevenlabs_ai ext |
| |
| Dial(SIP/elevenlabs/ |
| ${DID},120,tT) |
+-----------+------------+
|
SIP INVITE (G711 ulaw)
To: [email protected]
From: CallerID
|
+-----------v------------+
| ELEVENLABS CLOUD |
| |
| 1. Agent starts |
| 2. Calls getCallCtx |----> YOUR WEBHOOK SERVER
| webhook |<---- { company, trade, fee }
| 3. Greets caller |
| 4. Conversation... |
| 5. Calls createBook |----> YOUR WEBHOOK SERVER
| webhook |<---- { booking_id: 42 }
| 6. Confirms & hangs |
| up |
+------------------------+
YOUR SERVER
+-------------------+
| Webhook Endpoints |
| |
| /did_context.php |
| - Lookup DID |
| - Check repeat |
| - Return context|
| |
| /create_booking |
| .php |
| - Validate data |
| - INSERT into |
| bookings DB |
| - Return ID |
+-------------------+
|
+------v------+
| MariaDB |
| did_company |
| _map |
| ai_agent |
| _bookings |
+-------------+
Key Concepts
Server Tools (Webhooks): ElevenLabs lets you attach "server tools" to an agent. These are HTTP endpoints that the agent calls during a conversation. The agent decides when to call them based on the tool description and the conversation state. Tool responses can populate dynamic variables that the agent uses throughout the call.
Dynamic Variables: Values returned by webhook tools (like company_name, callout_fee) become variables the agent references in its prompt. This is how a single agent serves dozens of different company brands -- the webhook tells it which brand to use for each call.
SIP Trunk: ElevenLabs exposes a SIP endpoint (sip.rtc.elevenlabs.io:5060). You configure Asterisk to send calls there as a SIP peer. The DID (called number) and caller ID flow through the SIP headers, so ElevenLabs knows which number was dialed and who is calling.
G711 ulaw at 8kHz: The standard telephony codec. ElevenLabs natively supports ulaw_8000 for both input (ASR) and output (TTS), so there is no transcoding overhead.
3. Prerequisites
Accounts and API Keys
- ElevenLabs account with Conversational AI access (Scale plan or higher)
- API key from: https://elevenlabs.io/app/settings/api-keys
- A server running Asterisk (any version 16+) with SIP trunk capability
- A web server (Apache/Nginx + PHP or Python/Node.js) accessible from the internet for webhooks
Server Requirements
- Asterisk PBX with outbound SIP capability
- Web server with HTTPS (ElevenLabs webhooks require a reachable URL)
- MariaDB/MySQL for storing DID mappings and bookings
- PHP 7.4+ (or Python 3.8+, or Node.js 16+) for webhook endpoints
curlandpython3for the setup script- A public IP or domain name for your webhook server
Network Requirements
- Your Asterisk server must be able to send SIP to
sip.rtc.elevenlabs.io:5060(UDP and TCP) - ElevenLabs must be able to reach your webhook server over HTTPS (or HTTP with a
nip.iodomain for testing) - Firewall must allow outbound SIP (port 5060) and RTP (ports 10000-20000) from Asterisk to ElevenLabs
4. ElevenLabs Account and API Setup
Getting Your API Key
- Sign up at https://elevenlabs.io
- Navigate to Settings -> API Keys
- Create a new API key with Conversational AI permissions
- Save it securely -- you will need it for the setup script
# Store your API key (never commit this to git)
export EL_API_KEY="YOUR_EL_API_KEY"
Choosing a Voice
ElevenLabs offers hundreds of voices. For a UK home services dispatcher, you want:
- Accent: British English (Southern/RP-neutral -- not posh, not cockney)
- Tone: Warm, professional, conversational
- Speed: Natural pace, not too fast
Browse voices at https://elevenlabs.io/voice-library. Note the voice ID -- you will need it when creating the agent.
# List available voices via API
curl -s "https://api.elevenlabs.io/v1/voices" \
-H "xi-api-key: ${EL_API_KEY}" | python3 -m json.tool | head -50
Key API Endpoints
All Conversational AI endpoints live under https://api.elevenlabs.io/v1/convai:
| Endpoint | Method | Purpose |
|---|---|---|
/tools |
POST | Create a server tool (webhook) |
/agents/create |
POST | Create a new agent |
/agents/{id} |
PATCH | Update an existing agent |
/agents/{id} |
GET | Get agent details |
/phone-numbers |
POST | Import a phone number for SIP |
/conversations |
GET | List past conversations |
5. Creating Webhook Server Tools
Server tools are the mechanism by which ElevenLabs agents interact with your backend. Each tool is a webhook -- an HTTP endpoint that the agent calls with structured parameters and receives structured JSON back.
Tool 1: getCallContext
This tool is called at the very start of every conversation, before the agent speaks. It takes the DID (the number the customer dialed) and the caller ID, then returns the company name, trade type, callout fee, and whether this is a repeat customer.
#!/bin/bash
# Create the getCallContext server tool via ElevenLabs API
EL_API_KEY="${EL_API_KEY}"
EL_BASE="https://api.elevenlabs.io/v1/convai"
WEBHOOK_HOST="https://YOUR_SERVER_DOMAIN"
WEBHOOK_API_KEY="YOUR_WEBHOOK_API_KEY"
python3 -c "
import json, urllib.request
tool = {
'tool_config': {
'type': 'webhook',
'name': 'getCallContext',
'description': (
'Get the company context for this incoming call. '
'Returns company name, trade type, callout fee, and '
'whether the caller is a repeat customer. '
'Must be called at the very start of every call '
'before greeting the customer.'
),
'response_timeout_secs': 10,
'force_pre_tool_speech': False,
'api_schema': {
'url': '${WEBHOOK_HOST}/api/elevenlabs/did_context.php',
'method': 'POST',
'request_headers': {
'X-API-Key': '${WEBHOOK_API_KEY}'
},
'request_body_schema': {
'type': 'object',
'description': 'DID and caller ID for context lookup',
'properties': {
'did_number': {
'type': 'string',
'description': (
'The DID phone number the customer dialed, '
'digits only, e.g. 442039962952'
)
},
'caller_id': {
'type': 'string',
'description': (
'The customer phone number from caller ID, '
'digits only, e.g. 447963155448'
)
}
},
'required': ['did_number', 'caller_id']
},
'content_type': 'application/json'
},
'assignments': [
{'dynamic_variable': 'company_name',
'value_path': '\$.company_name'},
{'dynamic_variable': 'trade_type',
'value_path': '\$.trade_type'},
{'dynamic_variable': 'trade_label',
'value_path': '\$.trade_label'},
{'dynamic_variable': 'callout_fee',
'value_path': '\$.callout_fee'},
{'dynamic_variable': 'area',
'value_path': '\$.area'},
{'dynamic_variable': 'is_repeat',
'value_path': '\$.is_repeat'},
{'dynamic_variable': 'greeting',
'value_path': '\$.greeting'}
],
'tool_error_handling_mode': 'summarized',
'execution_mode': 'immediate'
}
}
data = json.dumps(tool).encode()
req = urllib.request.Request(
'${EL_BASE}/tools',
data=data,
headers={
'xi-api-key': '${EL_API_KEY}',
'Content-Type': 'application/json'
}
)
resp = urllib.request.urlopen(req)
result = json.loads(resp.read().decode())
print(json.dumps(result, indent=2))
print(f\"\\nTool ID: {result['id']}\")
"
Key configuration details:
| Field | Value | Why |
|---|---|---|
execution_mode |
immediate |
Tool runs before the agent speaks -- no awkward silence |
force_pre_tool_speech |
false |
Agent does not announce "let me look that up" |
response_timeout_secs |
10 |
Generous timeout for your webhook |
assignments |
7 variables | Each response field becomes a dynamic variable in the prompt |
tool_error_handling_mode |
summarized |
On failure, agent gets a short error message, not a stack trace |
The assignments array is critical. Each entry maps a JSON path from your webhook response to a dynamic variable name. After the tool runs, {{company_name}}, {{trade_type}}, {{callout_fee}}, etc. are all available in the agent's prompt context.
Tool 2: createBooking
This tool is called after the agent has collected all customer details (name, phone, postcode, address, problem description). It creates a booking record in your database.
# Create the createBooking server tool
python3 -c "
import json, urllib.request
tool = {
'tool_config': {
'type': 'webhook',
'name': 'createBooking',
'description': (
'Create a new job booking after collecting all customer '
'details including name, phone, postcode, address, and '
'problem description, plus context values from '
'getCallContext.'
),
'response_timeout_secs': 15,
'force_pre_tool_speech': True,
'api_schema': {
'url': '${WEBHOOK_HOST}/api/elevenlabs/create_booking.php',
'method': 'POST',
'request_headers': {
'X-API-Key': '${WEBHOOK_API_KEY}'
},
'request_body_schema': {
'type': 'object',
'description': 'Complete booking details',
'properties': {
'customer_name': {
'type': 'string',
'description': 'Customer full name in title case'
},
'customer_phone': {
'type': 'string',
'description': (
'Phone number with country code, no spaces, '
'e.g. 447963155448'
)
},
'postcode': {
'type': 'string',
'description': (
'Full UK postcode, uppercase with space, '
'e.g. E5 9ES'
)
},
'address': {
'type': 'string',
'description': (
'Full street address including flat '
'or house number'
)
},
'problem_description': {
'type': 'string',
'description': 'One-line summary of the issue'
},
'trade_type': {
'type': 'string',
'description': (
'From getCallContext: plumbing, electrical, '
'drainage, or locksmith'
),
'enum': [
'plumbing', 'electrical',
'drainage', 'locksmith'
]
},
'callout_fee': {
'type': 'number',
'description': 'Callout fee from getCallContext'
},
'did_number': {
'type': 'string',
'description': 'DID number from getCallContext'
},
'company_name': {
'type': 'string',
'description': (
'Company name from getCallContext'
)
},
'is_repeat': {
'type': 'boolean',
'description': (
'Whether repeat caller from getCallContext'
)
},
'outcome': {
'type': 'string',
'description': 'Always set to booked',
'enum': ['booked']
}
},
'required': [
'customer_name', 'customer_phone', 'postcode',
'address', 'problem_description', 'trade_type'
]
},
'content_type': 'application/json'
},
'tool_call_sound': 'typing',
'tool_call_sound_behavior': 'auto',
'tool_error_handling_mode': 'summarized',
'execution_mode': 'post_tool_speech'
}
}
data = json.dumps(tool).encode()
req = urllib.request.Request(
'${EL_BASE}/tools',
data=data,
headers={
'xi-api-key': '${EL_API_KEY}',
'Content-Type': 'application/json'
}
)
resp = urllib.request.urlopen(req)
result = json.loads(resp.read().decode())
print(json.dumps(result, indent=2))
print(f\"\\nTool ID: {result['id']}\")
"
Key differences from getCallContext:
| Field | Value | Why |
|---|---|---|
execution_mode |
post_tool_speech |
Agent speaks confirmation after booking completes |
force_pre_tool_speech |
true |
Agent says something like "bear with me" while booking processes |
tool_call_sound |
typing |
Plays a subtle typing sound while webhook processes |
response_timeout_secs |
15 |
Database insert might take a moment |
6. The Webhook Server: DID Context API
This endpoint is the heart of the dynamic context system. When a customer dials one of your phone numbers, ElevenLabs calls this webhook with the DID and caller ID. Your server looks up the DID in a mapping table and returns the company brand, trade type, callout fee, and repeat caller status.
PHP Implementation
<?php
/**
* ElevenLabs AI Agent -- DID Context API
*
* Called by ElevenLabs server tool "getCallContext" at start of each call.
* Returns company name, trade type, callout fee, repeat caller status.
*
* Endpoint: POST /api/elevenlabs/did_context.php
* Body: { "did_number": "442039962952", "caller_id": "447963155448" }
* Auth: X-API-Key header
*/
header('Content-Type: application/json');
// --- Authentication ---
$API_TOKEN = 'YOUR_WEBHOOK_API_KEY';
$auth = $_SERVER['HTTP_X_API_KEY'] ?? '';
if ($auth !== $API_TOKEN) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// --- Parse input ---
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON body']);
exit;
}
$did_number = preg_replace('/[^0-9]/', '', $input['did_number'] ?? '');
$caller_id = preg_replace('/[^0-9]/', '', $input['caller_id'] ?? '');
if (empty($did_number)) {
http_response_code(400);
echo json_encode(['error' => 'did_number is required']);
exit;
}
// --- Database connection ---
// Reads credentials from Asterisk config file.
// Replace with your own DB connection method.
$db_conf = [];
$lines = file('/etc/astguiclient.conf',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (preg_match('/^(VARDB_\w+)\s*=>\s*(.+)$/', $line, $m)) {
$db_conf[$m[1]] = trim($m[2]);
}
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8',
$db_conf['VARDB_server'] ?? 'localhost',
$db_conf['VARDB_port'] ?? '3306',
$db_conf['VARDB_database'] ?? 'asterisk'
);
try {
$pdo = new PDO(
$dsn,
$db_conf['VARDB_user'] ?? 'cron',
$db_conf['VARDB_pass'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Database connection failed']);
exit;
}
// --- Look up DID in mapping table ---
$stmt = $pdo->prepare(
'SELECT clean_name, trade_type, callout_fee, area
FROM did_company_map WHERE did = ?'
);
$stmt->execute([$did_number]);
$row = $stmt->fetch();
if (!$row) {
// Fallback for unmapped DIDs -- generic defaults
$result = [
'company_name' => 'Home Services',
'trade_type' => 'plumbing',
'trade_label' => 'engineer',
'callout_fee' => 49,
'area' => null,
'is_repeat' => false,
'greeting' => 'Hello, how can I help you?',
];
echo json_encode($result);
exit;
}
// --- Check for repeat caller (last 7 days) ---
$is_repeat = false;
if (!empty($caller_id)) {
$cutoff = date('Y-m-d H:i:s', time() - 604800); // 7 days
$stmt2 = $pdo->prepare(
'SELECT 1 FROM doppia_calls
WHERE phone_number = ? AND did = ?
AND last_call_time >= ? LIMIT 1'
);
$stmt2->execute([$caller_id, $did_number, $cutoff]);
$is_repeat = (bool)$stmt2->fetch();
}
// --- Build trade label ---
$trade_labels = [
'plumbing' => 'plumber',
'electrical' => 'electrician',
'drainage' => 'drainage engineer',
'locksmith' => 'locksmith',
];
$trade_label = $trade_labels[$row['trade_type']] ?? 'engineer';
// --- Build time-of-day greeting ---
$hour = (int)date('H');
if ($hour < 12) {
$time_greeting = 'good morning';
} elseif ($hour < 18) {
$time_greeting = 'good afternoon';
} else {
$time_greeting = 'good evening';
}
$greeting = "Hello, $time_greeting. How can I help you?";
// --- Return response ---
echo json_encode([
'company_name' => $row['clean_name'],
'trade_type' => $row['trade_type'],
'trade_label' => $trade_label,
'callout_fee' => (int)$row['callout_fee'],
'area' => $row['area'],
'is_repeat' => $is_repeat,
'did_number' => $did_number,
'caller_id' => $caller_id,
'greeting' => $greeting,
]);
Python (Flask) Alternative
If you prefer Python over PHP:
#!/usr/bin/env python3
"""
ElevenLabs AI Agent -- DID Context Webhook
Flask implementation for non-PHP environments.
"""
from flask import Flask, request, jsonify
from datetime import datetime, timedelta
import mysql.connector
import os
app = Flask(__name__)
API_KEY = os.environ.get('WEBHOOK_API_KEY', 'YOUR_WEBHOOK_API_KEY')
DB_CONFIG = {
'host': os.environ.get('DB_HOST', 'localhost'),
'port': int(os.environ.get('DB_PORT', 3306)),
'database': os.environ.get('DB_NAME', 'asterisk'),
'user': os.environ.get('DB_USER', 'cron'),
'password': os.environ.get('DB_PASS', 'YOUR_DB_PASSWORD'),
}
TRADE_LABELS = {
'plumbing': 'plumber',
'electrical': 'electrician',
'drainage': 'drainage engineer',
'locksmith': 'locksmith',
}
def get_time_greeting():
hour = datetime.now().hour
if hour < 12:
return 'good morning'
elif hour < 18:
return 'good afternoon'
return 'good evening'
@app.route('/api/elevenlabs/did_context', methods=['POST'])
def did_context():
# Auth check
if request.headers.get('X-API-Key') != API_KEY:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
if not data or 'did_number' not in data:
return jsonify({'error': 'did_number required'}), 400
did = ''.join(c for c in data['did_number'] if c.isdigit())
caller_id = ''.join(
c for c in data.get('caller_id', '') if c.isdigit()
)
conn = mysql.connector.connect(**DB_CONFIG)
cursor = conn.cursor(dictionary=True)
# Look up DID
cursor.execute(
'SELECT clean_name, trade_type, callout_fee, area '
'FROM did_company_map WHERE did = %s',
(did,)
)
row = cursor.fetchone()
if not row:
conn.close()
return jsonify({
'company_name': 'Home Services',
'trade_type': 'plumbing',
'trade_label': 'engineer',
'callout_fee': 49,
'area': None,
'is_repeat': False,
'greeting': 'Hello, how can I help you?',
})
# Check repeat caller
is_repeat = False
if caller_id:
cutoff = datetime.now() - timedelta(days=7)
cursor.execute(
'SELECT 1 FROM doppia_calls '
'WHERE phone_number = %s AND did = %s '
'AND last_call_time >= %s LIMIT 1',
(caller_id, did, cutoff)
)
is_repeat = cursor.fetchone() is not None
conn.close()
trade_label = TRADE_LABELS.get(row['trade_type'], 'engineer')
greeting = f"Hello, {get_time_greeting()}. How can I help you?"
return jsonify({
'company_name': row['clean_name'],
'trade_type': row['trade_type'],
'trade_label': trade_label,
'callout_fee': int(row['callout_fee']),
'area': row['area'],
'is_repeat': is_repeat,
'did_number': did,
'caller_id': caller_id,
'greeting': greeting,
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8090)
Response Format
Your webhook must return JSON with this structure:
{
"company_name": "George The Plumber",
"trade_type": "plumbing",
"trade_label": "plumber",
"callout_fee": 48,
"area": "London",
"is_repeat": false,
"did_number": "442039962952",
"caller_id": "447963155448",
"greeting": "Hello, good afternoon. How can I help you?"
}
Each field in the response maps to a dynamic variable via the assignments array in the tool configuration. The agent prompt can then reference {{company_name}}, {{callout_fee}}, etc.
7. The Webhook Server: Booking API
After the agent collects all customer details (name, postcode, address, problem), it calls this endpoint to persist the booking.
PHP Implementation
<?php
/**
* ElevenLabs AI Agent -- Create Booking API
*
* Called by ElevenLabs server tool "createBooking" after
* collecting customer details. Stores the booking in the database.
*
* Endpoint: POST /api/elevenlabs/create_booking.php
* Auth: X-API-Key header
*/
header('Content-Type: application/json');
// --- Authentication ---
$API_TOKEN = 'YOUR_WEBHOOK_API_KEY';
$auth = $_SERVER['HTTP_X_API_KEY'] ?? '';
if ($auth !== $API_TOKEN) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// --- Parse and validate input ---
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON body']);
exit;
}
$required = [
'customer_name', 'customer_phone', 'postcode',
'address', 'problem_description', 'trade_type'
];
foreach ($required as $field) {
if (empty($input[$field])) {
http_response_code(400);
echo json_encode([
'error' => "Missing required field: $field"
]);
exit;
}
}
// --- Sanitize ---
$customer_name = trim($input['customer_name']);
$customer_phone = preg_replace('/[^0-9+]/', '', $input['customer_phone']);
$postcode = strtoupper(trim($input['postcode']));
$address = trim($input['address']);
$problem = trim($input['problem_description']);
$trade_type = $input['trade_type'];
$callout_fee = (int)($input['callout_fee'] ?? 49);
$did_number = preg_replace('/[^0-9]/', '', $input['did_number'] ?? '');
$company_name = trim($input['company_name'] ?? '');
$is_repeat = !empty($input['is_repeat']);
$outcome = $input['outcome'] ?? 'booked';
// --- Database connection (same pattern as did_context.php) ---
$db_conf = [];
$lines = file('/etc/astguiclient.conf',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (preg_match('/^(VARDB_\w+)\s*=>\s*(.+)$/', $line, $m)) {
$db_conf[$m[1]] = trim($m[2]);
}
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8',
$db_conf['VARDB_server'] ?? 'localhost',
$db_conf['VARDB_port'] ?? '3306',
$db_conf['VARDB_database'] ?? 'asterisk'
);
try {
$pdo = new PDO(
$dsn,
$db_conf['VARDB_user'] ?? 'cron',
$db_conf['VARDB_pass'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Database connection failed']);
exit;
}
// --- Ensure bookings table exists ---
$pdo->exec("
CREATE TABLE IF NOT EXISTS ai_agent_bookings (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
customer_name VARCHAR(255) NOT NULL,
customer_phone VARCHAR(30) NOT NULL,
postcode VARCHAR(10) NOT NULL,
address VARCHAR(500) NOT NULL,
problem_description TEXT NOT NULL,
trade_type VARCHAR(20) NOT NULL,
callout_fee INT NOT NULL DEFAULT 49,
did_number VARCHAR(30) DEFAULT NULL,
company_name VARCHAR(255) DEFAULT NULL,
is_repeat TINYINT(1) DEFAULT 0,
outcome VARCHAR(30) DEFAULT 'booked',
dispatched TINYINT(1) DEFAULT 0,
notes TEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_phone (customer_phone),
KEY idx_created (created_at),
KEY idx_dispatched (dispatched)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
");
// --- Insert booking ---
$stmt = $pdo->prepare("
INSERT INTO ai_agent_bookings
(customer_name, customer_phone, postcode, address,
problem_description, trade_type, callout_fee,
did_number, company_name, is_repeat, outcome)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$customer_name, $customer_phone, $postcode, $address,
$problem, $trade_type, $callout_fee, $did_number,
$company_name, $is_repeat ? 1 : 0, $outcome,
]);
$booking_id = $pdo->lastInsertId();
// --- Response ---
echo json_encode([
'success' => true,
'booking_id' => (int)$booking_id,
'message' => "Booking #{$booking_id} created for "
. "{$customer_name} at {$postcode}",
]);
Testing Your Webhooks
Before wiring them into ElevenLabs, test both endpoints manually:
# Test getCallContext
curl -s -X POST https://YOUR_SERVER_DOMAIN/api/elevenlabs/did_context.php \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_WEBHOOK_API_KEY" \
-d '{"did_number": "442039962952", "caller_id": "447963155448"}' \
| python3 -m json.tool
# Test createBooking
curl -s -X POST https://YOUR_SERVER_DOMAIN/api/elevenlabs/create_booking.php \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_WEBHOOK_API_KEY" \
-d '{
"customer_name": "Test Customer",
"customer_phone": "447000000000",
"postcode": "SW1A 1AA",
"address": "10 Downing Street",
"problem_description": "Test booking from API",
"trade_type": "plumbing",
"callout_fee": 49,
"did_number": "442039962952",
"company_name": "Test Plumber",
"is_repeat": false,
"outcome": "booked"
}' | python3 -m json.tool
Expected responses:
// getCallContext
{
"company_name": "George The Plumber",
"trade_type": "plumbing",
"trade_label": "plumber",
"callout_fee": 48,
"area": "London",
"is_repeat": false,
"greeting": "Hello, good afternoon. How can I help you?"
}
// createBooking
{
"success": true,
"booking_id": 1,
"message": "Booking #1 created for Test Customer at SW1A 1AA"
}
8. Creating the AI Agent via API
With both tools created, you can now create the agent itself. The agent ties together the voice, the LLM, the system prompt, and the tools.
#!/bin/bash
# Create the ElevenLabs Conversational AI agent
EL_API_KEY="${EL_API_KEY}"
EL_BASE="https://api.elevenlabs.io/v1/convai"
# Tool IDs from the previous step
GET_CONTEXT_TOOL_ID="YOUR_TOOL_ID_1"
CREATE_BOOKING_TOOL_ID="YOUR_TOOL_ID_2"
# Voice ID -- choose from ElevenLabs voice library
VOICE_ID="YOUR_CHOSEN_VOICE_ID"
python3 -c "
import json, urllib.request
# Read the system prompt from a file (see Section 9)
prompt = open('agent_prompt.md').read()
agent = {
'name': 'UK Home Services Agent',
'tags': ['production', 'inbound', 'uk-trades'],
'conversation_config': {
'asr': {
'quality': 'high',
'provider': 'elevenlabs',
'user_input_audio_format': 'ulaw_8000',
'keywords': [
'postcode', 'plumber', 'electrician',
'drainage', 'locksmith', 'callout',
'leaking', 'tripped', 'blocked', 'socket',
'fuse box', 'EICR', 'boiler', 'radiator',
'Bravo', 'Charlie', 'Delta', 'Echo',
'Foxtrot', 'Golf', 'Hotel', 'India',
'Juliet', 'Kilo', 'Lima', 'Mike',
'November', 'Oscar', 'Papa', 'Quebec',
'Romeo', 'Sierra', 'Tango', 'Uniform',
'Victor', 'Whiskey', 'X-ray', 'Yankee',
'Zulu'
]
},
'turn': {
'turn_timeout': 10,
'silence_end_call_timeout': 15,
'turn_eagerness': 'patient',
'spelling_patience': 'auto'
},
'tts': {
'model_id': 'eleven_v3_conversational',
'voice_id': '${VOICE_ID}',
'agent_output_audio_format': 'ulaw_8000',
'stability': 0.55,
'speed': 1.0,
'similarity_boost': 0.75
},
'conversation': {
'max_duration_seconds': 300
},
'agent': {
'first_message': '',
'language': 'en',
'prompt': {
'prompt': prompt,
'llm': 'gpt-4o',
'temperature': 0.4,
'max_tokens': -1,
'tool_ids': [
'${GET_CONTEXT_TOOL_ID}',
'${CREATE_BOOKING_TOOL_ID}'
],
'ignore_default_personality': True
}
}
},
'platform_settings': {
'call_limits': {
'agent_concurrency_limit': 5,
'daily_limit': 500
}
}
}
data = json.dumps(agent).encode()
req = urllib.request.Request(
'${EL_BASE}/agents/create',
data=data,
headers={
'xi-api-key': '${EL_API_KEY}',
'Content-Type': 'application/json'
}
)
resp = urllib.request.urlopen(req)
result = json.loads(resp.read().decode())
print(json.dumps(result, indent=2))
print(f\"\\nAgent ID: {result['agent_id']}\")
"
Agent Configuration Deep Dive
ASR (Automatic Speech Recognition)
{
"quality": "high",
"provider": "elevenlabs",
"user_input_audio_format": "ulaw_8000",
"keywords": ["postcode", "plumber", ...]
}
ulaw_8000matches standard telephony audio -- no transcoding neededkeywordsarray boosts recognition accuracy for domain-specific terms- The NATO alphabet keywords (Bravo, Charlie, Delta...) are critical -- customers spell postcodes using these, and without the keyword boost, the STT will frequently misrecognize them
Turn Taking
{
"turn_timeout": 10,
"silence_end_call_timeout": 15,
"turn_eagerness": "patient",
"spelling_patience": "auto"
}
turn_eagerness: patientprevents the agent from jumping in too quickly. Callers with urgent plumbing problems tend to speak in long, rambling sentences. A trigger-happy agent that interrupts will frustrate them.silence_end_call_timeout: 15-- if 15 seconds of silence, the agent will prompt "Hello? Are you still there?" and eventually end the callspelling_patience: auto-- when a caller spells out a postcode letter by letter, the agent waits for them to finish rather than responding after each letter
TTS (Text-to-Speech)
{
"model_id": "eleven_v3_conversational",
"voice_id": "YOUR_CHOSEN_VOICE_ID",
"agent_output_audio_format": "ulaw_8000",
"stability": 0.55,
"speed": 1.0,
"similarity_boost": 0.75
}
eleven_v3_conversationalis optimized for low-latency dialogue (vs themultilingual_v2model which is better for long-form narration)stability: 0.55-- slightly lower stability makes the voice sound more natural and less robotic. Higher values (0.8+) sound monotone.similarity_boost: 0.75-- how closely the output matches the original voice sample. 0.75 is a good balance between consistency and naturalness.ulaw_8000output matches the SIP codec -- zero transcoding overhead
LLM
{
"llm": "gpt-4o",
"temperature": 0.4,
"ignore_default_personality": true
}
gpt-4oprovides the best balance of intelligence, speed, and cost for conversational agentstemperature: 0.4-- low enough to keep the agent on-script, high enough to sound naturalignore_default_personality: true-- removes ElevenLabs' default system prompt so your custom prompt has full control
Call Limits
{
"agent_concurrency_limit": 5,
"daily_limit": 500
}
- Concurrency limit prevents runaway costs if a traffic spike hits
- Daily limit is a safety net -- adjust based on your expected volume
9. Writing the Agent Prompt
The system prompt is the most important part of the entire setup. It defines the agent's personality, workflow, and guardrails. A poorly written prompt will produce an agent that sounds robotic, skips steps, or gives wrong information.
Prompt Structure
The prompt has several sections, each with a specific purpose:
# Personality
You are a friendly, professional call handler for a UK home services
company dispatching plumbers, electricians, drainage engineers, and
locksmiths. You sound like a real person working in a small local
trade office -- warm, efficient, and natural. You use casual British
English with light professional manners. You say "madam" and "sir"
occasionally, "no worries", "bear with me", and "lovely". You never
sound robotic or scripted.
# Environment
You are answering inbound phone calls from customers who found the
business on Google. Customers are usually calling because they have
an urgent home issue -- a leak, a tripped fuse, a blocked drain,
a lockout. They expect to speak to a real local tradesperson's office.
You serve many different company brands. At the very start of each
call, before greeting the caller, you must call the `getCallContext`
tool with the DID number and caller ID. This returns the company
name, trade type, callout fee, and whether this is a repeat caller.
Use these values for the entire call.
# Tone
Keep responses to 1-2 sentences at a time. Be conversational and
natural -- like a capable office worker, not a call centre script
reader. Use brief affirmations: "I see", "no worries", "lovely",
"perfect". Match the caller's energy -- if they're relaxed, be
chatty; if they're urgent, be quick and reassuring.
Never use American English. It is "postcode" not "zip code",
"labour" not "labor", "organised" not "organized".
# Strict workflow -- follow this order exactly
You MUST follow these steps in this exact order. NEVER skip ahead.
NEVER combine two steps into one response. Each step is a separate
turn.
**Step 1: Get context.** Call `getCallContext` with DID and caller
ID. This happens automatically before greeting.
**Step 2: Greet.** Use the greeting from context. If customer asks
"Is that [company name]?" confirm with the name from context.
**Step 3: Understand the problem.** Ask 2-3 follow-up questions to
properly understand the issue. Do NOT rush this. Examples:
- Plumbing: "Is it leaking right now?", "Which room?",
"Have you turned the water off?"
- Electrical: "Has the power tripped?", "Whole house or just
one room?", "Can you check the fuse box?"
- Drainage: "Is it a sink, toilet, or outside drain?",
"Any flooding?"
- Locksmith: "Are you locked out right now?", "House or car?"
Stay on this step until you understand the problem.
**Step 4: Quote time and fee.** ONLY after you understand the
problem: "I can get a {{trade_label}} out to you today, within
thirty minutes to an hour. There's a {{callout_fee}} pound callout
just for the {{trade_label}} to come out to you, and then he'll
have a look and give you a price for the work before he starts
anything."
CRITICAL: Do NOT ask for their postcode, address, or name in
the same turn as quoting the fee. Wait for their response first.
**Step 5: Collect postcode.** ONLY after the customer agrees to
the callout fee, ask: "Lovely, what's your postcode?" Confirm
using NATO phonetic alphabet.
**Step 6: Collect address.** After confirming the postcode, ask
for the street address and house or flat number.
**Step 7: Collect name.** Ask "And your name please?" once.
**Step 8: Book.** Use the caller_id from context as the phone
number -- do NOT ask for it. Call `createBooking` with all
collected data silently.
**Step 9: Confirm and close.** "That's booked for you. The
{{trade_label}} will be with you within the hour. Thanks for
calling."
# Things you must NEVER do
- NEVER combine the fee quote with asking for postcode or address
- NEVER ask "is this the best number to reach you on?"
- NEVER ask the customer to confirm their phone number
- NEVER repeat the customer's name back to them
- NEVER reconfirm or summarise all details at the end
- NEVER mention being an AI, virtual assistant, or automated system
- NEVER quote a labour cost or total job price
- NEVER give an exact arrival time -- say "thirty minutes to an
hour" or "within the hour"
- NEVER accept payment over the phone
# Handling objections
- Total price question: "The engineer will quote you on-site before
starting -- no obligation."
- Callout fee too much: "No worries at all. Thanks for calling."
Don't negotiate.
- Are you local: "Yes, one of our engineers is nearby right now."
- Gas work: "We handle {{trade_type}} rather than gas. You'd need
a Gas Safe engineer for that."
# Tools
## getCallContext
Call at the very start of every call before speaking.
**Parameters:**
- `did_number`: The DID (from `{{call.to}}`)
- `caller_id`: The customer's number (from `{{call.from}}`)
**Error fallback:** company_name="Home Services",
trade_label="engineer", callout_fee=49
## createBooking
Call after collecting ALL required information.
**Parameters:** customer_name, customer_phone, postcode, address,
problem_description, trade_type, callout_fee, did_number,
company_name, is_repeat, outcome
**Error fallback:** "Bear with me, I'm just having a small issue
logging this. Let me try again."
Key Prompt Engineering Insights
Why "NEVER combine steps": Without this explicit instruction, the LLM will try to be efficient and combine the fee quote with the postcode collection in a single turn. This sounds unnatural on a phone call -- customers need a moment to agree to the fee before being asked for personal details.
Why "do NOT ask for phone number": The caller ID is already captured by the SIP headers. Asking "What's your phone number?" on a voice call feels redundant and wastes time. The agent should silently use the caller ID from the getCallContext response.
Why the NATO alphabet keywords: UK postcodes contain letter-number-letter patterns (e.g., SW1A 1AA). Customers frequently spell these using the NATO phonetic alphabet ("Sierra Whiskey One Alpha"). Without the keyword boost in the ASR configuration, the STT engine will transcribe "Sierra" as "sierra" (the mountain range) or miss it entirely.
Why first_message is empty: The agent calls getCallContext before speaking. If you set a first_message, it would speak before knowing which company brand to use. The empty first message forces the agent to call the tool first, then use the greeting from the tool response.
10. Asterisk SIP Trunk to ElevenLabs
ElevenLabs exposes a SIP endpoint at sip.rtc.elevenlabs.io:5060. You configure Asterisk to send calls there as a standard SIP peer.
SIP Peer Configuration
Add this to your Asterisk SIP configuration (/etc/asterisk/sip.conf or /etc/asterisk/sip-vicidial.conf for ViciBox/ViciDial setups):
; --- ElevenLabs AI Voice Agent SIP Trunk ---
[elevenlabs]
type=peer
host=sip.rtc.elevenlabs.io
port=5060
transport=udp
disallow=all
allow=ulaw ; G711 ulaw -- native telephony codec
dtmfmode=rfc2833
insecure=port,invite
qualify=yes
qualifyfreq=60
context=from-elevenlabs ; inbound context (for any callbacks)
sendrpid=yes ; send caller ID information
trustrpid=yes
fromuser=YOUR_EL_AGENT_ID
fromdomain=sip.rtc.elevenlabs.io
Key settings explained:
| Setting | Value | Why |
|---|---|---|
host |
sip.rtc.elevenlabs.io |
ElevenLabs SIP endpoint |
allow |
ulaw |
Must match the ulaw_8000 format configured in the agent |
insecure |
port,invite |
ElevenLabs may send from different ports/IPs |
sendrpid |
yes |
Sends the original caller ID through to ElevenLabs |
fromuser |
Agent ID | Identifies which agent to connect to |
qualify |
yes |
Sends periodic OPTIONS to check connectivity |
After editing, reload the SIP configuration:
asterisk -rx "sip reload"
# Verify the peer is registered
asterisk -rx "sip show peer elevenlabs"
Firewall Rules
Ensure your firewall allows outbound SIP and RTP to ElevenLabs:
# Allow outbound SIP to ElevenLabs
iptables -A OUTPUT -d sip.rtc.elevenlabs.io -p udp --dport 5060 -j ACCEPT
# Allow RTP media (ElevenLabs will send audio back on high ports)
iptables -A INPUT -p udp --dport 10000:20000 -j ACCEPT
11. Asterisk Dialplan Routing
The dialplan tells Asterisk when and how to send calls to the ElevenLabs agent. The simplest approach is a dedicated extension that other parts of your dialplan can route to.
Basic Extension
Add this to your custom extensions file (typically /etc/asterisk/extensions_custom.conf or /etc/asterisk/customexte.conf):
; ==============================================
; ElevenLabs AI Voice Agent -- Route to Cloud AI
; ==============================================
;
; Route a call to the ElevenLabs agent via SIP trunk.
; The DID (${CALLED}) and caller ID flow through SIP headers.
; ElevenLabs agent calls our webhook to get context for that DID.
exten => elevenlabs_ai,1,NoOp(ELEVENLABS AI: DID=${CALLED} CLI=${CALLERID(num)})
same => n,Dial(SIP/elevenlabs/${CALLED},120,tT)
same => n,NoOp(ELEVENLABS DIAL RESULT: ${DIALSTATUS})
same => n,Hangup()
How it works:
NoOplogs the DID and caller ID for debuggingDial(SIP/elevenlabs/${CALLED},120,tT)-- sends the call to theelevenlabsSIP peer, dialing the DID as the destination number. ElevenLabs uses this DID to route to the correct agent. The120is a 2-minute ring timeout.tTallows both parties to transfer.- After the call ends,
${DIALSTATUS}is logged (ANSWER, NOANSWER, BUSY, etc.)
ViciDial Overflow Integration
If you are using ViciDial, the most common pattern is to route calls to ElevenLabs when no human agents are available. This is done through ViciDial's inbound group overflow settings:
- In ViciDial Admin, go to your inbound group
- Set No Agent No Queue or After Hours Action to route to a custom extension
- Point it to the
elevenlabs_aiextension in your custom extensions
Alternatively, you can route at the dialplan level with a fallback:
; Route to human agents first, fall back to AI
exten => overflow_to_ai,1,NoOp(OVERFLOW: No agents available for DID=${CALLED})
same => n,Set(CALLERID(name)=${CALLERID(num)})
same => n,Goto(default,elevenlabs_ai,1)
Multiple Agent Routing
If you have different ElevenLabs agents for different services (e.g., one for plumbing, one for electrical), you can route based on the DID:
; Route different DID ranges to different AI agents
exten => ai_router,1,NoOp(AI ROUTER: DID=${CALLED})
same => n,GotoIf($["${CALLED:0:5}" = "44203"]?plumbing_ai,1)
same => n,GotoIf($["${CALLED:0:5}" = "44207"]?electrical_ai,1)
same => n,Goto(default_ai,1)
exten => plumbing_ai,1,Dial(SIP/elevenlabs_plumbing/${CALLED},120,tT)
same => n,Hangup()
exten => electrical_ai,1,Dial(SIP/elevenlabs_electrical/${CALLED},120,tT)
same => n,Hangup()
exten => default_ai,1,Dial(SIP/elevenlabs/${CALLED},120,tT)
same => n,Hangup()
After adding or modifying dialplan entries, reload:
asterisk -rx "dialplan reload"
# Verify the extension exists
asterisk -rx "dialplan show elevenlabs_ai@default"
12. Database Schema
Two tables power the webhook context system.
did_company_map -- Maps DIDs to Company Brands
This table tells the webhook which company name, trade type, and pricing to return for each DID.
CREATE TABLE IF NOT EXISTS did_company_map (
did VARCHAR(50) NOT NULL,
company_name VARCHAR(255) NOT NULL
COMMENT 'Original/messy company name from import',
clean_name VARCHAR(255) NOT NULL
COMMENT 'Display name for AI agent to use',
trade_type ENUM('plumbing', 'electrical', 'drainage', 'locksmith')
NOT NULL DEFAULT 'plumbing',
callout_fee INT NOT NULL DEFAULT 49
COMMENT 'Callout fee in GBP',
area VARCHAR(100) DEFAULT NULL
COMMENT 'Service area: London, Birmingham, etc.',
PRIMARY KEY (did)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Example data
INSERT INTO did_company_map (did, company_name, clean_name,
trade_type, callout_fee, area) VALUES
('442039962952', 'george the plumber london',
'George The Plumber', 'plumbing', 48, 'London'),
('442071234567', 'robinson electrical services ltd',
'Robinson Electrical', 'electrical', 49, 'London'),
('441234567890', 'drain clear birmingham',
'DrainClear', 'drainage', 49, 'Birmingham'),
('442079876543', 'lock masters 24/7 emergency',
'Lock Masters', 'locksmith', 50, 'London');
ai_agent_bookings -- Stores AI-Created Bookings
CREATE TABLE IF NOT EXISTS ai_agent_bookings (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
customer_name VARCHAR(255) NOT NULL,
customer_phone VARCHAR(30) NOT NULL,
postcode VARCHAR(10) NOT NULL,
address VARCHAR(500) NOT NULL,
problem_description TEXT NOT NULL,
trade_type VARCHAR(20) NOT NULL,
callout_fee INT NOT NULL DEFAULT 49,
did_number VARCHAR(30) DEFAULT NULL,
company_name VARCHAR(255) DEFAULT NULL,
is_repeat TINYINT(1) DEFAULT 0,
outcome VARCHAR(30) DEFAULT 'booked'
COMMENT 'booked, declined, callback, cancelled',
dispatched TINYINT(1) DEFAULT 0
COMMENT '0=pending, 1=assigned to engineer',
notes TEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_phone (customer_phone),
KEY idx_created (created_at),
KEY idx_dispatched (dispatched)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Optional: Repeat Caller Detection Table
If you do not already have a call history table (like ViciDial's doppia_calls), you can create a simple one:
CREATE TABLE IF NOT EXISTS call_history (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
phone_number VARCHAR(30) NOT NULL,
did VARCHAR(50) NOT NULL,
last_call_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
call_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (id),
UNIQUE KEY idx_phone_did (phone_number, did),
KEY idx_last_call (last_call_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Update it from your webhook by adding an INSERT ... ON DUPLICATE KEY UPDATE after the DID lookup:
INSERT INTO call_history (phone_number, did, last_call_time, call_count)
VALUES (?, ?, NOW(), 1)
ON DUPLICATE KEY UPDATE
last_call_time = NOW(),
call_count = call_count + 1;
13. The Complete Setup Script
Here is a single self-contained bash script that creates both tools and the agent in one run. Save it as elevenlabs_setup.sh:
#!/bin/bash
#
# ElevenLabs AI Voice Agent -- Full Setup via API
# Creates: 2 server tools + 1 agent
#
# Usage:
# EL_API_KEY="your-key" \
# WEBHOOK_HOST="https://your-server.example.com" \
# WEBHOOK_API_KEY="your-webhook-secret" \
# VOICE_ID="your-voice-id" \
# bash elevenlabs_setup.sh
#
set -euo pipefail
# --- Configuration ---
EL_API_KEY="${EL_API_KEY:-}"
EL_BASE="https://api.elevenlabs.io/v1/convai"
WEBHOOK_HOST="${WEBHOOK_HOST:-}"
WEBHOOK_API_KEY="${WEBHOOK_API_KEY:-}"
VOICE_ID="${VOICE_ID:-}"
# --- Validate inputs ---
for var in EL_API_KEY WEBHOOK_HOST WEBHOOK_API_KEY VOICE_ID; do
if [ -z "${!var}" ]; then
echo "ERROR: Set ${var} environment variable."
exit 1
fi
done
echo "=== Step 1: Create getCallContext server tool ==="
GET_CONTEXT_RESP=$(python3 -c "
import json, urllib.request
tool = {
'tool_config': {
'type': 'webhook',
'name': 'getCallContext',
'description': (
'Get the company context for this incoming call. '
'Returns company name, trade type, callout fee, and '
'whether the caller is a repeat customer. Must be '
'called at the very start of every call before '
'greeting the customer.'
),
'response_timeout_secs': 10,
'force_pre_tool_speech': False,
'api_schema': {
'url': '${WEBHOOK_HOST}/api/elevenlabs/did_context.php',
'method': 'POST',
'request_headers': {
'X-API-Key': '${WEBHOOK_API_KEY}'
},
'request_body_schema': {
'type': 'object',
'description': 'DID and caller ID for context lookup',
'properties': {
'did_number': {
'type': 'string',
'description': (
'The DID phone number the customer '
'dialed, digits only'
)
},
'caller_id': {
'type': 'string',
'description': (
'The customer phone number from '
'caller ID, digits only'
)
}
},
'required': ['did_number', 'caller_id']
},
'content_type': 'application/json'
},
'assignments': [
{'dynamic_variable': 'company_name',
'value_path': '\$.company_name'},
{'dynamic_variable': 'trade_type',
'value_path': '\$.trade_type'},
{'dynamic_variable': 'trade_label',
'value_path': '\$.trade_label'},
{'dynamic_variable': 'callout_fee',
'value_path': '\$.callout_fee'},
{'dynamic_variable': 'area',
'value_path': '\$.area'},
{'dynamic_variable': 'is_repeat',
'value_path': '\$.is_repeat'},
{'dynamic_variable': 'greeting',
'value_path': '\$.greeting'}
],
'tool_error_handling_mode': 'summarized',
'execution_mode': 'immediate'
}
}
data = json.dumps(tool).encode()
req = urllib.request.Request(
'${EL_BASE}/tools',
data=data,
headers={
'xi-api-key': '${EL_API_KEY}',
'Content-Type': 'application/json'
}
)
resp = urllib.request.urlopen(req)
print(resp.read().decode())
")
GET_CONTEXT_TOOL_ID=$(echo "$GET_CONTEXT_RESP" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Tool ID: $GET_CONTEXT_TOOL_ID"
echo "=== Step 2: Create createBooking server tool ==="
CREATE_BOOKING_RESP=$(python3 -c "
import json, urllib.request
tool = {
'tool_config': {
'type': 'webhook',
'name': 'createBooking',
'description': (
'Create a new job booking after collecting all '
'customer details including name, phone, postcode, '
'address, and problem description.'
),
'response_timeout_secs': 15,
'force_pre_tool_speech': True,
'api_schema': {
'url': '${WEBHOOK_HOST}/api/elevenlabs/create_booking.php',
'method': 'POST',
'request_headers': {
'X-API-Key': '${WEBHOOK_API_KEY}'
},
'request_body_schema': {
'type': 'object',
'description': 'Complete booking details',
'properties': {
'customer_name': {
'type': 'string',
'description': 'Customer full name'
},
'customer_phone': {
'type': 'string',
'description': 'Phone with country code'
},
'postcode': {
'type': 'string',
'description': 'Full UK postcode'
},
'address': {
'type': 'string',
'description': 'Full street address'
},
'problem_description': {
'type': 'string',
'description': 'Summary of the issue'
},
'trade_type': {
'type': 'string',
'description': 'Trade type from context',
'enum': ['plumbing', 'electrical',
'drainage', 'locksmith']
},
'callout_fee': {
'type': 'number',
'description': 'Fee from context'
},
'did_number': {
'type': 'string',
'description': 'DID from context'
},
'company_name': {
'type': 'string',
'description': 'Company from context'
},
'is_repeat': {
'type': 'boolean',
'description': 'Repeat caller flag'
},
'outcome': {
'type': 'string',
'description': 'Always booked',
'enum': ['booked']
}
},
'required': ['customer_name', 'customer_phone',
'postcode', 'address',
'problem_description', 'trade_type']
},
'content_type': 'application/json'
},
'tool_call_sound': 'typing',
'tool_call_sound_behavior': 'auto',
'tool_error_handling_mode': 'summarized',
'execution_mode': 'post_tool_speech'
}
}
data = json.dumps(tool).encode()
req = urllib.request.Request(
'${EL_BASE}/tools',
data=data,
headers={
'xi-api-key': '${EL_API_KEY}',
'Content-Type': 'application/json'
}
)
resp = urllib.request.urlopen(req)
print(resp.read().decode())
")
CREATE_BOOKING_TOOL_ID=$(echo "$CREATE_BOOKING_RESP" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Tool ID: $CREATE_BOOKING_TOOL_ID"
echo "=== Step 3: Create the agent ==="
AGENT_RESP=$(python3 -c "
import json, urllib.request
prompt = open('agent_prompt.md').read()
agent = {
'name': 'UK Home Services Agent',
'tags': ['production', 'inbound', 'uk-trades'],
'conversation_config': {
'asr': {
'quality': 'high',
'provider': 'elevenlabs',
'user_input_audio_format': 'ulaw_8000',
'keywords': [
'postcode', 'plumber', 'electrician',
'drainage', 'locksmith', 'callout',
'Bravo', 'Charlie', 'Delta', 'Echo',
'Foxtrot', 'Golf', 'Hotel', 'India',
'Juliet', 'Kilo', 'Lima', 'Mike',
'November', 'Oscar', 'Papa', 'Quebec',
'Romeo', 'Sierra', 'Tango', 'Uniform',
'Victor', 'Whiskey', 'X-ray', 'Yankee',
'Zulu'
]
},
'turn': {
'turn_timeout': 10,
'silence_end_call_timeout': 15,
'turn_eagerness': 'patient',
'spelling_patience': 'auto'
},
'tts': {
'model_id': 'eleven_v3_conversational',
'voice_id': '${VOICE_ID}',
'agent_output_audio_format': 'ulaw_8000',
'stability': 0.55,
'speed': 1.0,
'similarity_boost': 0.75
},
'conversation': {
'max_duration_seconds': 300
},
'agent': {
'first_message': '',
'language': 'en',
'prompt': {
'prompt': prompt,
'llm': 'gpt-4o',
'temperature': 0.4,
'max_tokens': -1,
'tool_ids': [
'${GET_CONTEXT_TOOL_ID}',
'${CREATE_BOOKING_TOOL_ID}'
],
'ignore_default_personality': True
}
}
},
'platform_settings': {
'call_limits': {
'agent_concurrency_limit': 5,
'daily_limit': 500
}
}
}
data = json.dumps(agent).encode()
req = urllib.request.Request(
'${EL_BASE}/agents/create',
data=data,
headers={
'xi-api-key': '${EL_API_KEY}',
'Content-Type': 'application/json'
}
)
resp = urllib.request.urlopen(req)
print(resp.read().decode())
")
AGENT_ID=$(echo \"\$AGENT_RESP\" | \
python3 -c \"import sys,json; print(json.load(sys.stdin)['agent_id'])\")
echo \" Agent ID: \$AGENT_ID\"
echo \"\"
echo \"==========================================\"
echo \" SETUP COMPLETE\"
echo \"==========================================\"
echo \"\"
echo \"Agent ID: \$AGENT_ID\"
echo \"getCallContext tool: \$GET_CONTEXT_TOOL_ID\"
echo \"createBooking tool: \$CREATE_BOOKING_TOOL_ID\"
echo \"Voice ID: ${VOICE_ID}\"
echo \"\"
# Save IDs for later reference
cat > elevenlabs_ids.env <<ENVEOF
# ElevenLabs Agent IDs -- $(date)
EL_AGENT_ID="\$AGENT_ID"
EL_TOOL_GET_CONTEXT="\$GET_CONTEXT_TOOL_ID"
EL_TOOL_CREATE_BOOKING="\$CREATE_BOOKING_TOOL_ID"
EL_VOICE_ID="${VOICE_ID}"
EL_SIP_ENDPOINT="sip.rtc.elevenlabs.io"
EL_SIP_PORT="5060"
WEBHOOK_HOST="${WEBHOOK_HOST}"
ENVEOF
chmod 600 elevenlabs_ids.env
echo "IDs saved to elevenlabs_ids.env"
14. Testing the Integration
Step 1: Test Webhook Endpoints
# Test DID context lookup
curl -s -X POST https://YOUR_SERVER_DOMAIN/api/elevenlabs/did_context.php \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_WEBHOOK_API_KEY" \
-d '{"did_number":"442039962952","caller_id":"447000000000"}' \
| python3 -m json.tool
# Test booking creation
curl -s -X POST https://YOUR_SERVER_DOMAIN/api/elevenlabs/create_booking.php \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_WEBHOOK_API_KEY" \
-d '{
"customer_name":"Test User",
"customer_phone":"447000000000",
"postcode":"SW1A 1AA",
"address":"10 Test Street",
"problem_description":"Leaking tap",
"trade_type":"plumbing"
}' | python3 -m json.tool
Step 2: Test SIP Connectivity
# Check SIP peer status from Asterisk CLI
asterisk -rx "sip show peer elevenlabs"
# Verify DNS resolution
dig sip.rtc.elevenlabs.io
# Test SIP OPTIONS (from the Asterisk host)
sipsak -s sip:sip.rtc.elevenlabs.io -v
Step 3: Test a Live Call
The simplest test is to call one of your mapped DIDs from a mobile phone:
- Ensure the DID is set to overflow to the
elevenlabs_aiextension - Call the DID
- The AI agent should answer within 2-3 seconds
- It should greet you with the correct company name for that DID
- Walk through the full workflow: describe a problem, agree to the fee, give a postcode, address, and name
- Check your database for the new booking:
SELECT * FROM ai_agent_bookings ORDER BY id DESC LIMIT 1;
Step 4: Test Edge Cases
- Call a DID that is NOT in
did_company_map-- agent should use fallback ("Home Services") - Call the same number twice within 7 days -- agent should recognize the repeat caller
- Hang up mid-conversation -- verify no orphaned database records
- Refuse the callout fee -- agent should say "No worries" and end the call gracefully
- Ask "Are you a robot?" -- agent should deflect naturally
Step 5: Review Conversations in ElevenLabs Dashboard
Go to https://elevenlabs.io/app/conversational-ai/conversations to see:
- Full transcript of each conversation
- Audio playback
- Tool call logs (which webhooks were called, with what parameters, and what was returned)
- Latency metrics (time to first byte for each response)
- Duration and outcome
15. Production Deployment Tips
Webhook Security
Your webhook endpoints are publicly accessible. Secure them:
// Rate limiting (simple file-based)
$rate_file = '/tmp/el_rate_' . md5($_SERVER['REMOTE_ADDR']);
$requests = (int)@file_get_contents($rate_file);
if ($requests > 100) { // 100 requests per minute
http_response_code(429);
echo json_encode(['error' => 'Rate limited']);
exit;
}
file_put_contents($rate_file, $requests + 1);
// IP allowlisting (ElevenLabs IPs -- check their docs for current ranges)
$allowed_ips = ['X.X.X.X', 'Y.Y.Y.Y']; // ElevenLabs server IPs
if (!in_array($_SERVER['REMOTE_ADDR'], $allowed_ips)) {
// Fall back to API key check only
}
HTTPS for Webhooks
ElevenLabs can call HTTP endpoints, but HTTPS is strongly recommended for production. If you do not have a domain, you can use nip.io for testing:
http://YOUR-SERVER-IP.nip.io/api/elevenlabs/did_context.php
Replace dots in your IP with dashes (e.g., 1-2-3-4.nip.io resolves to 1.2.3.4).
For production, set up a proper domain with Let's Encrypt:
certbot --apache -d your-domain.com
Logging Webhook Calls
Add logging to your webhook endpoints so you can debug issues:
// At the top of did_context.php and create_booking.php
$log_dir = '/var/log/elevenlabs';
if (!is_dir($log_dir)) mkdir($log_dir, 0750, true);
$log_entry = date('Y-m-d H:i:s') . ' | '
. $_SERVER['REMOTE_ADDR'] . ' | '
. file_get_contents('php://input') . "\n";
file_put_contents("$log_dir/webhook.log", $log_entry, FILE_APPEND);
Agent Updates Without Downtime
To update the agent prompt or settings without creating a new agent:
# Patch the existing agent
curl -s -X PATCH "https://api.elevenlabs.io/v1/convai/agents/${AGENT_ID}" \
-H "xi-api-key: ${EL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"conversation_config": {
"agent": {
"prompt": {
"prompt": "YOUR_UPDATED_PROMPT_TEXT"
}
}
}
}'
Changes take effect on the next call -- there is no need to restart anything.
Graceful Degradation
If ElevenLabs is unreachable (SIP timeout, API outage), your dialplan should handle the failure:
; Enhanced routing with fallback
exten => elevenlabs_ai,1,NoOp(ELEVENLABS AI: DID=${CALLED})
same => n,Set(TIMEOUT(absolute)=130)
same => n,Dial(SIP/elevenlabs/${CALLED},120,tT)
same => n,NoOp(DIAL RESULT: ${DIALSTATUS})
same => n,GotoIf($["${DIALSTATUS}" = "CHANUNAVAIL"]?fallback,1)
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?fallback,1)
same => n,Hangup()
; Fallback: play a message or route to voicemail
exten => fallback,1,NoOp(AI UNAVAILABLE -- falling back)
same => n,Answer()
same => n,Playback(vm-nobodyavail)
same => n,VoiceMail(1000@default)
same => n,Hangup()
16. Monitoring and Logging
Asterisk CDR Monitoring
Track calls routed to ElevenLabs through Asterisk's CDR (Call Detail Records):
-- Calls routed to ElevenLabs in the last 24 hours
SELECT
calldate,
src AS caller_id,
dst AS did,
duration,
billsec,
disposition
FROM cdr
WHERE channel LIKE '%elevenlabs%'
OR dstchannel LIKE '%elevenlabs%'
AND calldate >= NOW() - INTERVAL 24 HOUR
ORDER BY calldate DESC;
ElevenLabs Conversation API
Pull conversation logs programmatically:
# List recent conversations
curl -s "https://api.elevenlabs.io/v1/convai/conversations?agent_id=${AGENT_ID}&limit=20" \
-H "xi-api-key: ${EL_API_KEY}" | python3 -m json.tool
# Get details for a specific conversation
curl -s "https://api.elevenlabs.io/v1/convai/conversations/${CONVERSATION_ID}" \
-H "xi-api-key: ${EL_API_KEY}" | python3 -m json.tool
Booking Dashboard Query
Monitor AI-created bookings:
-- Today's AI bookings summary
SELECT
trade_type,
COUNT(*) AS total_bookings,
SUM(dispatched = 0) AS pending_dispatch,
SUM(dispatched = 1) AS dispatched
FROM ai_agent_bookings
WHERE DATE(created_at) = CURDATE()
GROUP BY trade_type;
-- Undispatched bookings (need attention)
SELECT
id, created_at, customer_name, customer_phone,
postcode, trade_type, company_name,
problem_description
FROM ai_agent_bookings
WHERE dispatched = 0
AND outcome = 'booked'
ORDER BY created_at;
17. Cost Analysis
ElevenLabs Pricing (Conversational AI)
| Component | Cost | Notes |
|---|---|---|
| Agent minutes | ~$0.07-0.12/min | Depends on plan (Scale, Business) |
| Phone number (SIP) | Included | No per-number fee for SIP trunks |
| Overage | Per-minute | Scales with usage |
Example monthly cost for 500 calls/day at 2.5 min average:
500 calls x 2.5 min x 30 days = 37,500 minutes
37,500 x $0.08 = $3,000/month
Comparison: Cloud vs Local Stack Costs
| Component | Cloud (ElevenLabs) | Local (Tutorial 03 stack) |
|---|---|---|
| STT | Included | Deepgram: ~$0.006/min ($225/mo) |
| LLM | Included | Groq: ~$0.002/min ($75/mo) |
| TTS | Included | Cartesia: ~$0.015/min ($562/mo) |
| Server | None | VPS: ~$50-100/mo |
| Development | 1-2 days | 2-4 weeks |
| Maintenance | Zero | Ongoing |
| Total | ~$3,000/mo | ~$960/mo |
The local stack is cheaper per minute at scale but requires significant upfront development and ongoing maintenance. The cloud approach is more expensive per minute but has near-zero development and maintenance cost.
Break-Even Analysis
The cloud approach makes financial sense when:
- Your call volume is under ~15,000 minutes/month
- Developer time costs more than the cloud premium
- You need to launch quickly (days, not weeks)
- You do not have DevOps capacity to maintain the local stack
18. Comparison: Cloud vs Local Voice Agent
| Feature | Cloud (ElevenLabs) | Local (Tutorial 03) |
|---|---|---|
| Setup time | Hours | Weeks |
| Latency | ~400-800ms | ~200-250ms |
| Voice quality | Excellent (ElevenLabs TTS) | Excellent (Cartesia) |
| Customization | Prompt + tools | Full code control |
| Barge-in | Automatic | Custom implementation |
| Concurrent calls | Plan-limited | Server-limited |
| Audio format | G711 ulaw native | G711 via AudioSocket |
| Tool calling | Webhook-based | In-process Python |
| Monitoring | Dashboard + API | Custom logging |
| Recording | ElevenLabs stores | Asterisk MixMonitor |
| Failover | ElevenLabs SLA | Your responsibility |
| Data residency | ElevenLabs cloud | Your server |
| Cost at 1K calls/day | ~$3,000/mo | ~$960/mo |
| Maintenance | Zero | Ongoing |
When to Combine Both
Many production deployments use both approaches:
- Primary: Local voice agent (Tutorial 03) handles 80% of calls with lowest latency and cost
- Overflow: ElevenLabs cloud agent (this tutorial) handles the remaining 20% when the local agent is at capacity or during maintenance windows
The Asterisk dialplan can route dynamically:
; Route to local agent first, fall back to cloud
exten => smart_ai,1,NoOp(SMART ROUTING: DID=${CALLED})
same => n,Dial(SIP/127.0.0.1:9099/${CALLED},5)
same => n,GotoIf($["${DIALSTATUS}" != "ANSWER"]?cloud)
same => n,Hangup()
same => n(cloud),Dial(SIP/elevenlabs/${CALLED},120,tT)
same => n,Hangup()
19. Troubleshooting
SIP Issues
Problem: "CHANUNAVAIL" when dialing ElevenLabs
# Check SIP peer status
asterisk -rx "sip show peer elevenlabs"
# If "UNREACHABLE", check DNS
dig sip.rtc.elevenlabs.io
# Check firewall
iptables -L -n | grep 5060
# Test connectivity
nc -zvu sip.rtc.elevenlabs.io 5060
Problem: Call connects but no audio
- Verify
allow=ulawis set on the SIP peer - Check that RTP ports (10000-20000) are open inbound
- Ensure NAT settings are correct if Asterisk is behind NAT:
; Add to the elevenlabs SIP peer if behind NAT
nat=force_rport,comedia
directmedia=no
Problem: Caller ID not passing through
- Verify
sendrpid=yesandtrustrpid=yesin the SIP peer - Check that your trunk is not stripping caller ID
Webhook Issues
Problem: "Unauthorized" from webhook
- Verify
X-API-Keyheader matches the token in your PHP/Python code - Check that Apache/Nginx is not stripping custom headers:
# Apache: ensure headers pass through
SetEnvIf X-API-Key "(.*)" HTTP_X_API_KEY=$1
Problem: "Database connection failed"
- Verify your database credentials
- Check that the database user has SELECT permission on
did_company_mapand INSERT onai_agent_bookings - Test the connection manually:
mysql -u YOUR_DB_USER -p -e "SELECT 1"
Problem: Tool returns data but agent does not use it
- Verify the
assignmentsarray in the tool config matches the JSON paths in your response - Check that the JSONPath syntax is correct (
$.company_name, notcompany_name) - Review the conversation log in the ElevenLabs dashboard -- tool responses are shown there
Agent Behavior Issues
Problem: Agent combines the fee quote with asking for postcode
- Add explicit instructions in the prompt: "NEVER combine two steps into one response"
- Lower the temperature (try 0.3 instead of 0.4)
- Add negative examples: "BAD: 'There is a 49 pound callout. What is your postcode?' GOOD: 'There is a 49 pound callout...' [wait for response]"
Problem: Agent says "Thank you for calling [company name]"
- Add this to the NEVER list in the prompt
- Set
ignore_default_personality: truein the agent config
Problem: Agent uses American English
- Explicitly state "Never use American English" in the prompt
- Add specific corrections: "postcode not zip code, labour not labor"
- The
language: ensetting helps but is not sufficient on its own -- the prompt must reinforce British English
Problem: Agent does not call getCallContext
- Verify the tool description says "Must be called at the very start of every call"
- Verify
first_messageis empty (not set to a greeting) - Check
execution_mode: immediateis set on the tool
Summary
Building a cloud-hosted voice agent with ElevenLabs Conversational AI and Asterisk SIP integration involves five key pieces:
- Webhook tools that inject dynamic business context (company name, pricing, repeat caller status) into each call
- An agent with a carefully crafted prompt that follows a strict conversation workflow
- A SIP trunk in Asterisk that routes calls to ElevenLabs' endpoint
- A dialplan that decides which calls go to the AI (overflow, after-hours, etc.)
- A database that maps DIDs to company brands and stores AI-created bookings
The cloud approach trades per-minute cost for development speed and zero maintenance. For many businesses, especially those without dedicated DevOps staff, this is the right trade-off. You can have a production-ready AI voice agent answering real customer calls within a single day.
For those who need the absolute lowest latency, highest customization, or lowest per-minute cost at scale, the local approach from Tutorial 03 remains the better choice. And for maximum resilience, deploy both: local as primary, cloud as overflow.
This tutorial is based on a production deployment handling inbound UK home services calls across multiple trade types and company brands.