Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/telephony/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
Example showing how to handle DTMF (touch-tone) input with voice agents.

For setup instructions and more details, see the [main examples README](../README.md).

To connect this agent to Twilio or WhatsApp calls without a SIP trunk, see the [connector examples](./connectors/).
74 changes: 74 additions & 0 deletions examples/telephony/connectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Connector Examples

These examples connect phone and WhatsApp calls to a LiveKit agent with [LiveKit Connectors](https://docs.livekit.io/telephony/connectors/), with no SIP trunk to provision. Each call joins a LiveKit room as a regular participant, so the agent code is unchanged.

Both examples reuse the DTMF agent from [`../basic_dtmf_agent.py`](../basic_dtmf_agent.py). No new agent code is needed. The webhook servers dispatch that agent by its name (`my-telephony-agent` by default, or set `DTMF_AGENT_DISPATCH_NAME`).

## Overview

1. **Twilio (`twilio_connector.py`)**: A webhook server for [Twilio Media Streams](https://www.twilio.com/docs/voice/media-streams). When a call comes in, it calls `ConnectTwilioCall`, dispatches the agent, and returns TwiML that bridges the call into the room. A `dial` command places outbound calls through the Twilio REST API.
2. **WhatsApp (`whatsapp_connector.py`)**: A webhook server for the [WhatsApp Business Calling API](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling). It accepts inbound calls with `AcceptWhatsAppCall`, completes outbound calls with `ConnectWhatsAppCall`, and cleans up on the terminate event. A `dial` command starts outbound calls.

## Prerequisites

- A running agent. In one terminal: `python ../basic_dtmf_agent.py dev`
- A LiveKit Cloud project with connectors enabled, and `LIVEKIT_URL`, `LIVEKIT_API_KEY`, and `LIVEKIT_API_SECRET` in your environment.
- A publicly reachable URL for the webhook server. For local development, a tunnel like ngrok works: `ngrok http 8080`.

Both servers verify webhook signatures by default and refuse to start without the secrets that verification needs: `TWILIO_AUTH_TOKEN` plus `TWILIO_WEBHOOK_URL` for Twilio, and `WHATSAPP_APP_SECRET` for WhatsApp. An unauthenticated webhook lets anyone trigger connector calls, so pass `serve --allow-unverified` only for local testing. Meta separately requires a verify token to register the webhook at all. The server always enforces that handshake.

## Twilio

| Variable | Purpose |
|---|---|
| `TWILIO_ACCOUNT_SID` | Account SID, for the `dial` command only |
| `TWILIO_AUTH_TOKEN` | Auth token. Signs inbound webhooks and authenticates the `dial` command |
| `TWILIO_FROM_NUMBER` | Your Twilio number, for the `dial` command only |
| `TWILIO_WEBHOOK_URL` | The exact webhook URL configured in the Twilio console. Twilio signs it, so verification uses it verbatim |

1. Run the server: `python twilio_connector.py serve` (add `--allow-unverified` to skip signature verification while testing)
2. In the [Twilio Console](https://console.twilio.com), set your phone number's "A call comes in" webhook to `https://<your-tunnel>/twilio/voice` with HTTP POST.
3. Call your Twilio number. The agent answers.

For an outbound call: `python twilio_connector.py dial --to +15551234567`

## WhatsApp

| Variable | Purpose |
|---|---|
| `WHATSAPP_PHONE_NUMBER_ID` | The business phone number ID from your Meta app |
| `WHATSAPP_API_KEY` | A Meta access token |
| `WHATSAPP_CLOUD_API_VERSION` | Cloud API version, default `26.0`. Use a version [the connector supports](https://docs.livekit.io/telephony/connectors/whatsapp/). |
| `WHATSAPP_VERIFY_TOKEN` | A string you choose. Meta requires it when registering the webhook and echoes it in the verification handshake |
| `WHATSAPP_APP_SECRET` | Your Meta app's App Secret (App Dashboard, Settings, Basic), which Meta uses to sign webhook payloads. Not the verify token |

1. Enable calling on your WhatsApp Business number and subscribe your app to the `calls` webhook field. See the [Meta setup guide](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks).
2. Run the server: `python whatsapp_connector.py serve` (add `--allow-unverified` to skip signature verification while testing)
3. Register `https://<your-tunnel>/whatsapp/webhook` as the webhook URL with your verify token.
4. Call your business number from WhatsApp. The agent answers.

For an outbound call, keep the server running and run: `python whatsapp_connector.py dial --to 15551234567`

Outbound WhatsApp calling requires [user permission](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/user-call-permissions) and is not available in every country.

## Warm transfer

Twilio connector calls support the warm transfer workflow in [`../warm-transfer/`](../warm-transfer/). The customer leg needs no changes. There are two ways to reach the supervisor:

- `WarmTransferTask` dials the supervisor over a SIP trunk that LiveKit manages.
- `TwilioConnectorWarmTransferTask` dials the supervisor through the Twilio connector with your own Twilio credentials, so no trunk is needed. See [`../warm-transfer/twilio_connector_warm_transfer.py`](../warm-transfer/twilio_connector_warm_transfer.py).

To try it with these examples, set the Twilio variables above plus `LIVEKIT_SUPERVISOR_PHONE_NUMBER`, run the warm transfer agent instead of the DTMF agent, and point the webhook server at its dispatch name:

```bash
python ../warm-transfer/twilio_connector_warm_transfer.py dev # registers as "telephony-support-agent"
DTMF_AGENT_DISPATCH_NAME=telephony-support-agent python twilio_connector.py serve
```

Calls then reach a support agent that escalates to a supervisor when asked, with the supervisor dialed through the connector too. Warm transfer on WhatsApp connector calls is not supported yet.

## Connectors and SIP

Connectors are one of two telephony paths. SIP trunking (see [`../amd.py`](../amd.py) and [`../bank-ivr/`](../bank-ivr/)) stays the recommended path when you are starting fresh: it works with any provider and LiveKit manages routing with dispatch rules. Use the connector when your call logic already lives in Twilio, or for WhatsApp, which has no phone number to trunk.

For setup instructions and more details, see the [main examples README](../../README.md).
206 changes: 206 additions & 0 deletions examples/telephony/connectors/twilio_connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Connect Twilio phone calls to a LiveKit agent with the Twilio connector.

The connector uses Twilio Media Streams instead of a SIP trunk. Your webhook
answers Twilio's request with TwiML that points at a LiveKit WebSocket URL,
and the call joins a LiveKit room as a regular participant.

This example reuses the DTMF agent from ../basic_dtmf_agent.py. Run that agent
in another terminal, then run this server. See README.md for setup.

Docs: https://docs.livekit.io/telephony/connectors/twilio/
"""

import argparse
import asyncio
import base64
import hashlib
import hmac
import logging
import os
import re
import time
from collections.abc import Mapping

from aiohttp import BasicAuth, ClientSession, web
from dotenv import load_dotenv

from livekit import api
from livekit.protocol.agent_dispatch import RoomAgentDispatch

load_dotenv()

logger = logging.getLogger("twilio-connector-example")
logger.setLevel(logging.INFO)

# Must match the dispatch name the agent registered with.
AGENT_NAME = os.getenv("DTMF_AGENT_DISPATCH_NAME", "my-telephony-agent")
PORT = int(os.getenv("PORT", "8080"))

# Twilio credentials are needed for the `dial` command. When the auth token is
# set, inbound webhook signatures are verified with it too.
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN")
TWILIO_FROM_NUMBER = os.getenv("TWILIO_FROM_NUMBER")
# The exact public URL configured in the Twilio console. Twilio signs this
# URL, so signature verification needs it verbatim.
TWILIO_WEBHOOK_URL = os.getenv("TWILIO_WEBHOOK_URL")

TWIML = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{connect_url}" />
</Connect>
</Response>"""


def mask(number: str) -> str:
"""Keep phone numbers out of logs, matching the other telephony examples."""
return f"...{number[-4:]}" if len(number) > 4 else "****"


def redact_numbers(text: str) -> str:
"""Mask phone-number-like digit runs inside provider payloads before logging."""
return re.sub(r"\+?\d{7,15}", lambda m: mask(m.group()), text)


FAILURE_TWIML = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>We are unable to connect your call right now. Please try again later.</Say>
</Response>"""


def twilio_signature_valid(request: web.Request, form: Mapping[str, str]) -> bool:
"""Check the X-Twilio-Signature header: HMAC-SHA1 over the public URL
followed by the sorted form parameters, keyed with the auth token."""
assert TWILIO_AUTH_TOKEN and TWILIO_WEBHOOK_URL
payload = TWILIO_WEBHOOK_URL + "".join(k + form[k] for k in sorted(form.keys()))
expected = base64.b64encode(
hmac.new(TWILIO_AUTH_TOKEN.encode(), payload.encode(), hashlib.sha1).digest()
).decode()
return hmac.compare_digest(expected, request.headers.get("X-Twilio-Signature", ""))


async def handle_voice_webhook(request: web.Request) -> web.Response:
"""Answer Twilio's inbound call webhook with TwiML that bridges the call
into a LiveKit room and dispatches the agent."""
form = await request.post()

if request.app["verify_signatures"] and not twilio_signature_valid(request, form):
logger.warning("Rejected webhook with a bad signature")
return web.Response(status=403)

call_sid = form.get("CallSid", "")
caller = form.get("From", "")
logger.info(f"Inbound call {call_sid} from {mask(caller)}")

lkapi: api.LiveKitAPI = request.app["lkapi"]
try:
res = await lkapi.connector.connect_twilio_call(
api.ConnectTwilioCallRequest(
twilio_call_direction=api.ConnectTwilioCallRequest.TWILIO_CALL_DIRECTION_INBOUND,
room_name=f"call-{call_sid}",
participant_identity=caller,
participant_name=caller,
agents=[RoomAgentDispatch(agent_name=AGENT_NAME)],
)
)
except api.TwirpError as e:
# Answer with TwiML either way: an HTTP error plays an error tone to the caller.
logger.error(f"Connector rejected call {call_sid}: {e.code}: {redact_numbers(e.message)}")
return web.Response(text=FAILURE_TWIML, content_type="text/xml")
except Exception:
logger.exception(f"Failed to connect call {call_sid}")
return web.Response(text=FAILURE_TWIML, content_type="text/xml")

logger.info(f"Bridging call {call_sid} into room call-{call_sid}")
return web.Response(text=TWIML.format(connect_url=res.connect_url), content_type="text/xml")


def build_app(verify_signatures: bool) -> web.Application:
app = web.Application()
app["verify_signatures"] = verify_signatures
app.router.add_post("/twilio/voice", handle_voice_webhook)

async def _lkapi_ctx(app: web.Application):
app["lkapi"] = api.LiveKitAPI()
yield
await app["lkapi"].aclose()

app.cleanup_ctx.append(_lkapi_ctx)
return app


async def dial(to_number: str) -> None:
"""Place an outbound call through the connector.

The flow has two steps. First, ConnectTwilioCall returns a WebSocket URL
and pre-joins the room. The connector participant stays hidden until the
callee answers. Second, the Twilio REST API creates the call with the
HTTPS form of that URL, which returns the TwiML above when Twilio fetches it.
"""
if not (TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and TWILIO_FROM_NUMBER):
logger.error("Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER to dial")
raise SystemExit(1)

room_name = f"call-out-{int(time.time())}"
async with api.LiveKitAPI() as lkapi:
try:
res = await lkapi.connector.connect_twilio_call(
api.ConnectTwilioCallRequest(
twilio_call_direction=api.ConnectTwilioCallRequest.TWILIO_CALL_DIRECTION_OUTBOUND,
room_name=room_name,
participant_identity=to_number,
agents=[RoomAgentDispatch(agent_name=AGENT_NAME)],
)
)
except api.TwirpError as e:
logger.error(f"Connector rejected the call: {e.code}: {redact_numbers(e.message)}")
raise SystemExit(1) from None

# Twilio fetches TwiML over HTTPS from the same single-use URL.
twiml_url = res.connect_url.replace("wss://", "https://", 1)

async with ClientSession() as session:
resp = await session.post(
f"https://api.twilio.com/2010-04-01/Accounts/{TWILIO_ACCOUNT_SID}/Calls.json",
data={"To": to_number, "From": TWILIO_FROM_NUMBER, "Url": twiml_url},
auth=BasicAuth(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN),
)
body = await resp.json()
if resp.status >= 400:
logger.error(f"Twilio call creation failed: {redact_numbers(str(body))}")
raise SystemExit(1)
logger.info(f"Dialing {mask(to_number)}, Twilio call SID {body['sid']}, room {room_name}")


def main() -> None:
logging.basicConfig(level=logging.INFO)
parser = argparse.ArgumentParser(description="Twilio connector example")
sub = parser.add_subparsers(dest="command", required=True)
serve_parser = sub.add_parser("serve", help="run the inbound-call webhook server")
serve_parser.add_argument(
"--allow-unverified",
action="store_true",
help="run without webhook signature verification (local testing only)",
)
dial_parser = sub.add_parser("dial", help="place an outbound call")
dial_parser.add_argument("--to", required=True, help="number to call, E.164 format")
args = parser.parse_args()

if args.command == "serve":
verify = bool(TWILIO_AUTH_TOKEN and TWILIO_WEBHOOK_URL) and not args.allow_unverified
if not verify:
if not args.allow_unverified:
logger.error(
"Set TWILIO_AUTH_TOKEN and TWILIO_WEBHOOK_URL to verify webhook"
" signatures, or pass --allow-unverified for local testing"
)
raise SystemExit(1)
logger.warning("Webhook signature verification is disabled")
web.run_app(build_app(verify), port=PORT)
else:
asyncio.run(dial(args.to))


if __name__ == "__main__":
main()
Loading
Loading