← All Tutorials

ElevenLabs Cloud Voice Agent with Asterisk SIP Integration

AI & Voice Agents Advanced 46 min read #23

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

  1. Cloud vs Local Voice Agents
  2. Architecture Overview
  3. Prerequisites
  4. ElevenLabs Account and API Setup
  5. Creating Webhook Server Tools
  6. The Webhook Server: DID Context API
  7. The Webhook Server: Booking API
  8. Creating the AI Agent via API
  9. Writing the Agent Prompt
  10. Asterisk SIP Trunk to ElevenLabs
  11. Asterisk Dialplan Routing
  12. Database Schema
  13. The Complete Setup Script
  14. Testing the Integration
  15. Production Deployment Tips
  16. Monitoring and Logging
  17. Cost Analysis
  18. Comparison: Cloud vs Local Voice Agent
  19. 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)

When to Use Local (AudioSocket + Deepgram + Groq + Cartesia)

What This Tutorial Builds

An ElevenLabs-powered voice agent that:


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

Server Requirements

Network Requirements


4. ElevenLabs Account and API Setup

Getting Your API Key

  1. Sign up at https://elevenlabs.io
  2. Navigate to Settings -> API Keys
  3. Create a new API key with Conversational AI permissions
  4. 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:

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", ...]
}

Turn Taking

{
    "turn_timeout": 10,
    "silence_end_call_timeout": 15,
    "turn_eagerness": "patient",
    "spelling_patience": "auto"
}

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
}

LLM

{
    "llm": "gpt-4o",
    "temperature": 0.4,
    "ignore_default_personality": true
}

Call Limits

{
    "agent_concurrency_limit": 5,
    "daily_limit": 500
}

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:

  1. NoOp logs the DID and caller ID for debugging
  2. Dial(SIP/elevenlabs/${CALLED},120,tT) -- sends the call to the elevenlabs SIP peer, dialing the DID as the destination number. ElevenLabs uses this DID to route to the correct agent. The 120 is a 2-minute ring timeout. tT allows both parties to transfer.
  3. 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:

  1. In ViciDial Admin, go to your inbound group
  2. Set No Agent No Queue or After Hours Action to route to a custom extension
  3. Point it to the elevenlabs_ai extension 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:

  1. Ensure the DID is set to overflow to the elevenlabs_ai extension
  2. Call the DID
  3. The AI agent should answer within 2-3 seconds
  4. It should greet you with the correct company name for that DID
  5. Walk through the full workflow: describe a problem, agree to the fee, give a postcode, address, and name
  6. Check your database for the new booking:
SELECT * FROM ai_agent_bookings ORDER BY id DESC LIMIT 1;

Step 4: Test Edge Cases

Step 5: Review Conversations in ElevenLabs Dashboard

Go to https://elevenlabs.io/app/conversational-ai/conversations to see:


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:


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:

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

; Add to the elevenlabs SIP peer if behind NAT
nat=force_rport,comedia
directmedia=no

Problem: Caller ID not passing through

Webhook Issues

Problem: "Unauthorized" from webhook

# Apache: ensure headers pass through
SetEnvIf X-API-Key "(.*)" HTTP_X_API_KEY=$1

Problem: "Database connection failed"

mysql -u YOUR_DB_USER -p -e "SELECT 1"

Problem: Tool returns data but agent does not use it

Agent Behavior Issues

Problem: Agent combines the fee quote with asking for postcode

Problem: Agent says "Thank you for calling [company name]"

Problem: Agent uses American English

Problem: Agent does not call getCallContext


Summary

Building a cloud-hosted voice agent with ElevenLabs Conversational AI and Asterisk SIP integration involves five key pieces:

  1. Webhook tools that inject dynamic business context (company name, pricing, repeat caller status) into each call
  2. An agent with a carefully crafted prompt that follows a strict conversation workflow
  3. A SIP trunk in Asterisk that routes calls to ElevenLabs' endpoint
  4. A dialplan that decides which calls go to the AI (overflow, after-hours, etc.)
  5. 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.

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation