VonyxDocs

Pipecat

Run a Pipecat bot on Vonyx calls through LiveKit SIP.

View this page as Markdown

A Pipecat bot can make and take calls on your Vonyx number. Vonyx connects to LiveKit SIP over your SIP trunk, and the bot joins the call's LiveKit room with Pipecat's LiveKitTransport.

Phone ⇄ Vonyx ⇄ (SIP) ⇄ LiveKit SIP ⇄ LiveKit room ⇄ Pipecat bot (LiveKitTransport)

Why LiveKit, not Daily or a WebSocket serializer

Pipecat's Daily SIP route doesn't fit a username/password trunk: Daily's sipUri dial-out has no field for SIP credentials, and Daily dial-in expects the carrier to forward each call to a per-room SIP URI, while Vonyx forwards to one fixed inbound address. Pipecat's Twilio, Telnyx, Plivo and Exotel serializers only speak those providers' WebSocket formats. If you already run Asterisk on Vonyx, Pipecat lists a community-maintained Asterisk serializer (pipecat-asterisk) that Pipecat doesn't test or support. A native Vonyx WebSocket route for Pipecat is coming.

Before you start

  • A Vonyx SIP trunk with a number attached
  • A LiveKit Cloud project with its LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET
  • The LiveKit CLI signed in to that project
  • Python with Pipecat and the LiveKit extra, plus keys for your STT, LLM and TTS services
pip install "pipecat-ai[livekit,deepgram,openai,cartesia,silero]" fastapi uvicorn python-dotenv
.env
LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your_livekit_api_key
LIVEKIT_API_SECRET=your_livekit_api_secret
LIVEKIT_SIP_TRUNK_ID=ST_xxxxxxxx
DEEPGRAM_API_KEY=...
OPENAI_API_KEY=...
CARTESIA_API_KEY=...

Write the bot

One bot file serves both directions. It joins a LiveKit room as a participant. With a number to dial, it places the call itself and greets the callee once they answer. Without one, it waits for the caller who is already in the room.

bot.py
import asyncio
import os
 
from dotenv import load_dotenv
from livekit import api
from loguru import logger
 
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
    LLMContextAggregatorPair,
    LLMUserAggregatorParams,
)
from pipecat.runner.livekit import generate_token_with_agent
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.livekit.transport import LiveKitParams, LiveKitTransport
from pipecat.workers.runner import WorkerRunner
 
load_dotenv(override=True)
 
GREETING = "Hi, this is the assistant. How can I help?"
 
 
async def run_bot(room_name: str, dial_to: str | None = None):
    token = generate_token_with_agent(
        room_name, "Pipecat Bot", os.environ["LIVEKIT_API_KEY"], os.environ["LIVEKIT_API_SECRET"]
    )
    transport = LiveKitTransport(
        url=os.environ["LIVEKIT_URL"],
        token=token,
        room_name=room_name,
        params=LiveKitParams(audio_in_enabled=True, audio_out_enabled=True),
    )
 
    stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
    llm = OpenAILLMService(
        api_key=os.environ["OPENAI_API_KEY"],
        settings=OpenAILLMService.Settings(
            system_instruction="You are a helpful phone assistant. Keep replies short and spoken.",
        ),
    )
    tts = CartesiaTTSService(
        api_key=os.environ["CARTESIA_API_KEY"],
        settings=CartesiaTTSService.Settings(voice="YOUR_CARTESIA_VOICE_ID"),
    )
 
    context = LLMContext()
    user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
        context, user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer())
    )
    pipeline = Pipeline([
        transport.input(),
        stt,
        user_aggregator,
        llm,
        tts,
        transport.output(),
        assistant_aggregator,
    ])
    worker = PipelineWorker(pipeline)
 
    async def dial():
        lkapi = api.LiveKitAPI()
        try:
            await lkapi.sip.create_sip_participant(api.CreateSIPParticipantRequest(
                sip_trunk_id=os.environ["LIVEKIT_SIP_TRUNK_ID"],
                sip_call_to=dial_to,
                room_name=room_name,
                participant_identity="callee",
                wait_until_answered=True,
            ))
        except api.SipCallError as e:
            logger.error(f"Call failed: {e.sip_status_code} {e.sip_status}")
            await worker.cancel()
            return
        finally:
            await lkapi.aclose()
        await worker.queue_frame(TTSSpeakFrame(GREETING))
 
    @transport.event_handler("on_connected")
    async def on_connected(transport):
        if dial_to:
            asyncio.create_task(dial())
 
    @transport.event_handler("on_first_participant_joined")
    async def on_first_participant_joined(transport, participant_id):
        if not dial_to:  # inbound: the caller is already on the line
            await worker.queue_frame(TTSSpeakFrame(GREETING))
 
    @transport.event_handler("on_participant_left")
    async def on_participant_left(transport, participant_id, reason):
        await worker.cancel()  # the phone side hung up
 
    runner = WorkerRunner()
    await runner.add_workers(worker)
    await runner.run()

Dialling waits for the answer (wait_until_answered=True) before the greeting, so the callee doesn't miss it while the phone rings.

Outbound calls

Create the LiveKit outbound trunk

Follow Outbound calls in the LiveKit guide to create an outbound trunk with your Vonyx address, trunk username, password and number. Put its trunk ID (ST_…) in LIVEKIT_SIP_TRUNK_ID.

Start the bot with a number

call.py
import asyncio
import uuid
 
from bot import run_bot
 
asyncio.run(run_bot(f"out-{uuid.uuid4().hex[:8]}", dial_to="91XXXXXXXXXX"))
python call.py

The bot joins a new room, LiveKit dials the number through Vonyx, and the bot speaks once the callee answers. The callee sees the Vonyx number on the outbound trunk as caller ID. To use a different one of your Vonyx numbers per call, set sip_number on the CreateSIPParticipantRequest.

Inbound calls

LiveKit SIP creates a room for each call. A Pipecat bot isn't a LiveKit Agents worker, so LiveKit can't dispatch it. Instead, a LiveKit webhook tells your server when a call room starts, and the server runs the bot in it.

Create the inbound trunk and point Vonyx at it

Follow Inbound calls in the LiveKit guide to find your LiveKit SIP endpoint, create the inbound trunk and set it as the trunk's inbound address in the Vonyx console. Skip that guide's dispatch rule and use the one below.

Add a dispatch rule without an agent

This rule puts each caller in a new room prefixed call-. It has no room_config.agents, because your webhook starts the bot.

dispatch-rule.json
{
  "dispatch_rule": {
    "name": "Vonyx to Pipecat",
    "trunk_ids": ["ST_inbound_xxxxxxxx"],
    "rule": {
      "dispatch_rule_individual": {
        "room_prefix": "call-"
      }
    }
  }
}
lk sip dispatch create dispatch-rule.json

Run a webhook server

The server checks LiveKit's signature, then starts the bot for every room_started event on a call- room. Outbound rooms use the out- prefix, so they are ignored.

server.py
import asyncio
 
from fastapi import FastAPI, HTTPException, Request
from livekit import api
 
from bot import run_bot
 
app = FastAPI()
receiver = api.WebhookReceiver(api.TokenVerifier())  # reads LIVEKIT_API_KEY / LIVEKIT_API_SECRET
 
 
@app.post("/livekit/webhook")
async def livekit_webhook(request: Request):
    body = (await request.body()).decode()
    try:
        event = receiver.receive(body, request.headers.get("Authorization", ""))
    except Exception:
        raise HTTPException(status_code=401)
 
    if event.event == "room_started" and event.room.name.startswith("call-"):
        asyncio.create_task(run_bot(event.room.name))
    return {"ok": True}
uvicorn server:app --port 8000

The server must be reachable from the internet (for local testing, use a tunnel).

Register the webhook in LiveKit Cloud

In LiveKit Cloud, open Settings → Webhooks → Create new webhook. Enter a Name, set the URL to https://your-server.example.com/livekit/webhook, pick the Signing API key that matches LIVEKIT_API_KEY, and select Create.

Hang up from the bot

Stopping the pipeline only takes the bot out of the room; the caller would hear silence. To end the call, delete the room:

lkapi = api.LiveKitAPI()
await lkapi.room.delete_room(api.DeleteRoomRequest(room=room_name))
await lkapi.aclose()

Test the connection

  1. Trunk first: check that lk sip participant create reaches your phone, as in the LiveKit guide. That rules out trunk problems before the bot is involved.
  2. Outbound: run python call.py with your own phone number. Your phone should ring with your Vonyx number, and the bot should greet you when you answer.
  3. Inbound: with server.py running and the webhook registered, call your Vonyx number. A call-… room should appear in LiveKit Cloud with a SIP participant and Pipecat Bot, and the bot should greet you.

Limitations

  • DTMF and call transfer are not available on Vonyx yet. Don't rely on Pipecat's on_dtmf_event, OutputDTMFFrame or DTMFAggregator, or on LiveKit SIP transfers.
  • New accounts make 1 call at a time (raise limits).
  • Codecs are PCMU and PCMA only, with no TLS or SRTP. LiveKit SIP handles the conversion to WebRTC audio for the bot.
  • Caller ID must be one of your Vonyx numbers.
  • The bot code above runs one bot per call inside the webhook server's process. Under real traffic, run bots as separate processes or jobs.

Troubleshooting

ProblemFix
Call failed: 401 or 407The trunk username or password on the LiveKit outbound trunk is wrong.
Call failed: 486 or 603The callee rejected the call.
Call failed: 408 or 480No answer, or the callee was unreachable.
Outbound call rejected right awayCheck the outbound trunk's number is one of your Vonyx numbers and the dialled number is 91XXXXXXXXXX.
The callee hears nothing after answeringCheck the bot logs for STT, LLM or TTS key errors. The greeting is only queued after wait_until_answered returns.
Inbound room appears but the bot never joinsThe webhook isn't reaching server.py, the signature check fails (wrong signing key), or the room name doesn't start with call-. Use Send a test event on the webhook in LiveKit Cloud.
Inbound call never reaches LiveKitCheck Inbound calls is on and the inbound address has no sip: prefix. See the LiveKit guide.
Caller hears silence after the bot stopsDelete the room to hang up (see Hang up from the bot).
Second parallel call failsNew accounts have 1 channel. See limits.
Call connects with no audioSee SIP troubleshooting. Vonyx sends RTP over UDP 10000–20000.