Complete project scaffolding and core implementation of an AI-powered telephony system that calls companies, navigates IVR menus, waits on hold, and transfers to the user when a human answers. Key components: - FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces - SIP/VoIP call management via PJSUA2 with RTP audio streaming - LLM-powered IVR navigation using OpenAI/Anthropic with tool calling - Hold detection service combining audio analysis and silence detection - Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines - Call recording with per-channel and mixed audio capture - Event bus (asyncio pub/sub) for real-time client updates - Web dashboard with live call monitoring - SQLite persistence via SQLAlchemy with call history and analytics - Notification support (email, SMS, webhook, desktop) - Docker Compose deployment with Opal VoIP and Opal Media containers - Comprehensive test suite with unit, integration, and E2E tests - Simplified .gitignore and full project documentation in README
7.0 KiB
7.0 KiB
API Reference
Hold Slayer exposes a REST API, WebSocket endpoint, and MCP server.
REST API
Base URL: http://localhost:8000/api
Calls
Place an Outbound Call
POST /api/calls/outbound
Request:
{
"number": "+18005551234",
"mode": "hold_slayer",
"intent": "dispute Amazon charge from December 15th",
"device": "sip_phone",
"call_flow_id": "chase_bank_disputes",
"services": {
"recording": true,
"transcription": true
}
}
Call Modes:
| Mode | Description |
|---|---|
direct |
Dial and connect to your device immediately |
hold_slayer |
Navigate IVR, wait on hold, transfer when human detected |
ai_assisted |
Connect with noise cancel, transcription, recording |
Response:
{
"call_id": "call_abc123",
"status": "trying",
"number": "+18005551234",
"mode": "hold_slayer",
"started_at": "2026-01-15T10:30:00Z"
}
Launch Hold Slayer
POST /api/calls/hold-slayer
Convenience endpoint — equivalent to POST /outbound with mode=hold_slayer.
Request:
{
"number": "+18005551234",
"intent": "dispute Amazon charge from December 15th",
"call_flow_id": "chase_bank_disputes",
"transfer_to": "sip_phone"
}
Get Call Status
GET /api/calls/{call_id}
Response:
{
"call_id": "call_abc123",
"status": "on_hold",
"number": "+18005551234",
"mode": "hold_slayer",
"duration": 847,
"hold_time": 780,
"audio_type": "music",
"transcript_excerpt": "...your call is important to us...",
"classification_history": [
{"timestamp": 1706000000, "type": "ringing", "confidence": 0.95},
{"timestamp": 1706000003, "type": "ivr_prompt", "confidence": 0.88},
{"timestamp": 1706000010, "type": "music", "confidence": 0.92}
],
"services": {"recording": true, "transcription": true}
}
List Active Calls
GET /api/calls
Response:
{
"calls": [
{"call_id": "call_abc123", "status": "on_hold", "number": "+18005551234", "duration": 847},
{"call_id": "call_def456", "status": "connected", "number": "+18009876543", "duration": 120}
],
"total": 2
}
End a Call
POST /api/calls/{call_id}/hangup
Transfer a Call
POST /api/calls/{call_id}/transfer
Request:
{
"device": "sip_phone"
}
Call Flows
List Call Flows
GET /api/call-flows
GET /api/call-flows?company=Chase+Bank
GET /api/call-flows?tag=banking
Response:
{
"flows": [
{
"id": "chase_bank_disputes",
"name": "Chase Bank — Disputes",
"company": "Chase Bank",
"phone_number": "+18005551234",
"step_count": 7,
"success_count": 12,
"fail_count": 1,
"tags": ["banking", "disputes"]
}
]
}
Get Call Flow
GET /api/call-flows/{flow_id}
Returns the full call flow with all steps.
Create Call Flow
POST /api/call-flows
Request:
{
"name": "Chase Bank — Disputes",
"company": "Chase Bank",
"phone_number": "+18005551234",
"steps": [
{"id": "wait", "type": "WAIT", "description": "Wait for greeting", "timeout": 5.0, "next_step": "menu"},
{"id": "menu", "type": "LISTEN", "description": "Main menu", "next_step": "press3"},
{"id": "press3", "type": "DTMF", "description": "Account services", "dtmf": "3", "next_step": "hold"},
{"id": "hold", "type": "HOLD", "description": "Wait for agent", "next_step": "transfer"},
{"id": "transfer", "type": "TRANSFER", "description": "Connect to user"}
]
}
Update Call Flow
PUT /api/call-flows/{flow_id}
Delete Call Flow
DELETE /api/call-flows/{flow_id}
Devices
List Registered Devices
GET /api/devices
Response:
{
"devices": [
{
"id": "dev_001",
"name": "Office SIP Phone",
"type": "sip_phone",
"sip_uri": "sip:robert@gateway.helu.ca",
"is_online": true,
"priority": 10
}
]
}
Register a Device
POST /api/devices
Request:
{
"name": "Office SIP Phone",
"type": "sip_phone",
"sip_uri": "sip:robert@gateway.helu.ca",
"priority": 10,
"capabilities": ["voice"]
}
Update Device
PUT /api/devices/{device_id}
Remove Device
DELETE /api/devices/{device_id}
Error Responses
All errors follow a consistent format:
{
"detail": "Call not found: call_xyz789"
}
| Status Code | Meaning |
|---|---|
400 |
Bad request (invalid parameters) |
404 |
Resource not found (call, flow, device) |
409 |
Conflict (call already ended, device already registered) |
500 |
Internal server error |
WebSocket
Event Stream
ws://localhost:8000/ws/events
ws://localhost:8000/ws/events?call_id=call_abc123
ws://localhost:8000/ws/events?types=human_detected,hold_detected
Query Parameters:
| Param | Description |
|---|---|
call_id |
Filter events for a specific call |
types |
Comma-separated event types to receive |
Event Format:
{
"type": "hold_detected",
"call_id": "call_abc123",
"timestamp": "2026-01-15T10:35:00Z",
"data": {
"audio_type": "music",
"confidence": 0.92,
"hold_duration": 0
}
}
Event Types
| Type | Data Fields |
|---|---|
call_started |
number, mode, intent |
call_ringing |
number |
call_connected |
number, duration |
call_ended |
number, duration, reason |
call_failed |
number, error |
hold_detected |
audio_type, confidence |
human_detected |
confidence, transcript_excerpt |
transfer_started |
device, from_call_id |
transfer_complete |
device, bridge_id |
ivr_step |
step_id, step_type, description |
ivr_dtmf_sent |
digits, step_id |
ivr_menu_detected |
transcript, options |
audio_classified |
audio_type, confidence, features |
transcript_chunk |
text, speaker, is_final |
recording_started |
recording_id, path |
recording_stopped |
recording_id, duration, file_size |
Client Example
const ws = new WebSocket("ws://localhost:8000/ws/events");
ws.onopen = () => {
console.log("Connected to Hold Slayer events");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "human_detected":
alert("🚨 A live person picked up! Pick up your phone!");
break;
case "hold_detected":
console.log("⏳ On hold...");
break;
case "transcript_chunk":
console.log(`📝 ${data.data.speaker}: ${data.data.text}`);
break;
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
Python Client Example
import asyncio
import websockets
import json
async def listen():
async with websockets.connect("ws://localhost:8000/ws/events") as ws:
async for message in ws:
event = json.loads(message)
print(f"[{event['type']}] {event.get('data', {})}")
asyncio.run(listen())