apps/telephony. The package ships two — Vobiz and Plivo — and they are deliberately structured identically, so the fastest way to add a third is to open both folders side by side and follow the shape.
Quick reference
Create, and only these:apps/telephony/xml.py, calls.py, serializers.py, registry.py, the package’s own __init__.py, schema.py. All dispatch by lookup — no vendor names ever appear there.
One required edit outside your folder: apps/telephony/tests/test_registry.py — add your provider id to the frozenset in test_registered_providers_include_vobiz_and_plivo and to the parametrised test lists. Skipping this doesn’t break anything at import time, but it’s the only thing that catches a half-registered provider (e.g. config registered but no client, or no answer-XML builder).
Only legitimate exception: apps/runtime/requirements.txt, if your frame serializer needs a Pipecat extra not already installed.
Read Telephony model first for what an application, an answer URL, and a frame serializer are. This page assumes those.
apps/telephony/README.md covers the package’s public API and the registration decorators in more detail.The folder contract
A telephony provider is a folder underapps/telephony/providers/<name>/ with ten modules. Both existing vendors have exactly this set.
The split between
client.py and application.py / recording.py is not ceremony. client.py holds only what is provider-account specific — how auth headers are shaped, how the account path is built — and the other two hold the calls. Vobiz sends X-Auth-ID and X-Auth-Token headers under /Account/{auth_id}/; another vendor might use HTTP basic auth. Changing that is a client.py edit and nothing else.
__init__.py must not import serializers or serializer_service. Both existing vendors say so in their module docstring. apps/api (which has no Pipecat installed) reaches your provider only through the parent package — apps.telephony.registry and apps.telephony itself, e.g. apps/api/app/services/agent_telephony_service.py and outbound_call_service.py — never by importing a vendor submodule directly. A serializer import at package level would still break it the moment apps.telephony is imported, since Python runs __init__.py for the whole package.Config, Auth and Settings
Same three-layer stack asapps/providers, with bases from apps/telephony/base.py:
@register_telephonydecorates the class, not a function. It reads theproviderfield’s default and puts the class intoTELEPHONY_CONFIGS. Registering the same id twice raisesValueErrorat import.- Both credential fields are
secret: True. They land inProviderAuth, Fernet-encrypted withPROVIDER_AUTH_ENCRYPTION_KEY. See Provider credentials. integration_modelnames the legacy credential key. Copy the pattern for a new vendor:"integration_model": "AcmeAuthId".
base_url belongs on Settings, never on Auth. Ship a DEFAULT_*_API_BASE_URL constant so the field is optional, and set allow_custom_input=True if the vendor has regional endpoints.
BaseTelephonyAuth declares auth_id and auth_token, so a vendor that authenticates with something else — a single bearer token, say — should still express it through those two names or override them outright. Consistency here is what lets initiate_outbound() build a config for any provider from the same three arguments.
Registering a client
service.py is short. It does two registrations and nothing else:
@register_client works the same way @register_stt does in apps/providers: it reads the annotation on the first parameter, resolves the config class, and takes the provider id from that class’s provider default. There is no string argument to get wrong.
load_providers() in apps/telephony/registry.py imports both config and service for every package under providers/, so the moment your folder exists with those two modules, the provider is registered. A vendor package missing either module is skipped rather than raising.
Once registered, three call paths reach your client without further wiring:
{status, message, ...} dicts built by success() and fail() from apps/telephony/base.py; recording helpers return ids, bytes, or None.
Use the shared HTTP helpers rather than reaching for httpx directly. request_json() and request_bytes() in base.py already handle status errors, connection errors, and empty bodies, and return (data, error_message) so the caller never has to catch.
Registering answer XML
The second half ofservice.py:
@register_answer_xml takes the provider id as a string, because an XML builder has no typed config parameter to read it from.
The format itself lives in xml.py, and it is a plain function returning a string:
/answer route calls the parent dispatcher and never a provider module:
sample_rate comes from the SAMPLE_RATE environment variable (8000 or 16000) via apps/runtime/constants.py.
Optional frame serializer
A frame serializer translates between the provider’s WebSocket media protocol and Pipecat frames. It is optional because the API never needs it — only the runtime does — and it is the one part of the folder that requires Pipecat. If Pipecat already ships a serializer for your vendor, re-export it:serializers.py — Vobiz does, in about a hundred lines.
Either way, serializer_service.py registers a factory:
__init__.py.
The laziness is enforced by two separate loaders in apps/telephony/registry.py:
load_providers() imports only config and service. load_frame_serializers() is a second, separate walk that imports serializer_service and is only reached through get_frame_serializer_factory(). The runtime triggers it implicitly by calling create_frame_serializer(); the API never does.
Note the signature difference: PlivoFrameSerializer takes stream_id / call_id while VobizFrameSerializer takes stream_sid / call_sid. The serializer_service.py wrapper is where you absorb that — the parent create_frame_serializer() always takes stream_sid and call_sid, and every caller uses those names.
Never add if/elif to the facades
apps/telephony/xml.py, calls.py, and serializers.py are dispatch facades at the package root. Each one looks up a registered callable and calls it:
ValueError for an unregistered provider — Unsupported telephony provider for XML: 'twilio' — and apps/telephony/tests/test_registry.py asserts that message for all three lookups.
Adding a vendor should touch exactly one new directory. If your diff edits xml.py, calls.py, serializers.py, or registry.py, something has gone wrong. The one legitimate exception is apps/runtime/requirements.txt, when your serializer needs a Pipecat extra that is not already installed.
Testing
Six modules inapps/telephony/tests cover the package. Run them from the repository root:
test_registry.py is parametrised over ["vobiz", "plivo"] and asserts the registered set exactly:
test_each_provider_has_config_client_and_xml then checks your id is in all three of TELEPHONY_CONFIGS, CLIENT_CREATORS, and ANSWER_XML_BUILDERS, and test_each_provider_has_frame_serializer_after_lazy_load checks the serializer map after the lazy import.
Add your own XML test alongside test_xml.py’s existing cases — the exact string a provider expects is the thing most likely to be wrong, and the hardest to notice, because a malformed <Response> produces a call that connects and then goes silent.
You can also dump the catalog to eyeball what the API will serve:
There is no CI. Run these yourself before opening a pull request, and test a real inbound call against the vendor’s sandbox — the registry tests prove the wiring, not that the vendor accepts your XML.