Skip to main content
ElevenLabs Documentation Docs

Search documentation

Type to search this documentation.

On this pageOverview

Azure Communication Services

Let users dial a phone number that your ElevenLabs agent answers, via ACS Call Automation.

This approach gives your agent a phone number. A caller dials it, Azure Communication Services (ACS) answers with bidirectional media streaming, and a small bridge relays PCM audio between ACS and the ElevenLabs agent using the standard agent WebSocket protocol. It’s the contact-center / IVR pattern — the same shape as a SIP trunking deployment, with ACS as the carrier.

It also connects to Teams two ways: a Teams user with a Calling Plan can dial the ACS number directly, or you can front the number with Teams Phone Extensibility so calls to a Teams resource account route into ACS.

A caller dials the ACS number; ACS fires IncomingCall via Event Grid to the bridge, which answers with bidirectional PCM 16k media streaming and relays it to the ElevenLabs agent over a WebSocket

Inbound call → ACS → bridge → ElevenLabs

Audio is PCM 16 kHz mono on both legs (the agent’s input/output format is pcm_16000), so it passes through as base64 with no resampling.

The bridge exposes these routes:

Route Purpose
POST /api/incomingCall Event Grid webhook: validates the subscription, then answer_call with media streaming
POST /api/callbacks Call Automation lifecycle events (CallConnected, CallDisconnected, AddParticipant*)
GET|WS /ws ACS media-streaming socket ↔ ElevenLabs
POST /api/outboundCall Optional: place an outbound call that connects the answerer to the agent
  1. A paid Azure subscription (MCA / EA / Pay-As-You-Go) — free/trial/sponsorship subs cannot buy numbers.
  2. An Azure Communication Services resource.
  3. An HTTPS host for the bridge with a public WebSocket (Azure Container Apps, App Service, or a VM).
  4. An ElevenLabs agent set to PCM 16000 Hz on both legs: TTS output format on the Voice tab, user input audio format on the Advanced tab.
Scope Role / permission Why
Azure RBAC Contributor on the resource group create the ACS resource, Container App, and Event Grid subscription
Azure subscription Owner or Contributor on the subscription purchase phone numbers (the buy option is disabled otherwise)
Billing MCA / EA / Pay-As-You-Go subscription type free, trial, sponsorship, and Dev subscriptions cannot buy numbers

Step 1 — Provision the ACS resource and number

Section titled “Step 1 — Provision the ACS resource and number”
Bash
RG=my-rg
# Register providers (once)
az provider register -n Microsoft.Communication --wait
az provider register -n Microsoft.EventGrid --wait

# Create the ACS resource
az communication create --name my-acs --resource-group $RG \
  --location global --data-location unitedstates

Buy a number in the resource (Portal → your ACS resource → Phone numbers → Get, or the phone-numbers SDK). For an agent that answers calls, a number with inbound calling is enough; add outbound capability if you also want /api/outboundCall.

The ACS resource Phone numbers blade listing active numbers with their calling capabilities

Phone numbers on the ACS resource — the Calling column shows each number's direction

To verify from the CLI (requires az extension add --name communication), and to fetch the connection string the bridge uses as ACS_CONNECTION_STRING:

Bash
CONN=$(az communication list-key -n my-acs -g $RG --query primaryConnectionString -o tsv)
az communication phonenumber list --connection-string "$CONN" --query "[].phoneNumber"

The bridge is a small Flask + flask-sock app using azure-communication-callautomation. The core of the inbound flow:

bridge.py (excerpt)
from azure.communication.callautomation import (
    CallAutomationClient, MediaStreamingOptions, StreamingTransportType,
    MediaStreamingContentType, MediaStreamingAudioChannelType, AudioFormat,
)

@app.route("/api/incomingCall", methods=["POST"])
def incoming_call():
    for event in request.get_json():
        # Event Grid subscription validation handshake
        if event.get("eventType") == "Microsoft.EventGrid.SubscriptionValidationEvent":
            return jsonify({"validationResponse": event["data"]["validationCode"]})

        if event.get("eventType") == "Microsoft.Communication.IncomingCall":
            client = CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING)
            client.answer_call(
                incoming_call_context=event["data"]["incomingCallContext"],
                callback_url=f"https://{HOST}/api/callbacks",
                media_streaming=MediaStreamingOptions(
                    transport_url=f"wss://{HOST}/ws",
                    transport_type=StreamingTransportType.WEBSOCKET,
                    content_type=MediaStreamingContentType.AUDIO,
                    audio_channel_type=MediaStreamingAudioChannelType.MIXED,
                    start_media_streaming=True,
                    enable_bidirectional=True,
                    audio_format=AudioFormat.PCM16_K_MONO,
                ),
            )
    return jsonify({"status": "ok"})

On the /ws socket, relay PCM16 both ways: forward ACS AudioData frames to ElevenLabs as {"user_audio_chunk": "<base64>"}, and send the agent’s audio back as {"Kind":"AudioData","AudioData":{"Data":"<base64>"},"StopAudio":null}. The first frame ACS sends is AudioMetadata (the negotiated format) — log it and ignore it. The ElevenLabs side is the standard agent WebSocket protocol.

bridge.py — media relay
import asyncio, json, os, queue, threading, websockets
from flask_sock import Sock

sock = Sock(app)
AGENT_ID = os.environ["ELEVENLABS_AGENT_ID"]
# US default; data residency: wss://api.eu.residency.elevenlabs.io, .in., or .sg.
EL_ORIGIN = os.environ.get("ELEVENLABS_ORIGIN", "wss://api.elevenlabs.io")
EL_WS = f"{EL_ORIGIN}/v1/convai/conversation?agent_id={AGENT_ID}"

@sock.route("/ws")
def media_stream(ws):
    loop = asyncio.new_event_loop()
    el = {"ws": None}
    to_acs = queue.Queue()  # outbound frames; only this handler thread touches `ws`

    async def el_session():
        async with websockets.connect(EL_WS) as elws:
            el["ws"] = elws
            await elws.send(json.dumps({"type": "conversation_initiation_client_data"}))
            async for msg in elws:
                data = json.loads(msg)
                kind = data.get("type")
                if kind == "audio":  # agent audio -> caller
                    b64 = data["audio_event"]["audio_base_64"]
                    to_acs.put({"Kind": "AudioData", "AudioData": {"Data": b64}, "StopAudio": None})
                elif kind == "ping":
                    await elws.send(json.dumps({"type": "pong", "event_id": data["ping_event"]["event_id"]}))
                elif kind == "interruption":  # barge-in
                    to_acs.put({"Kind": "StopAudio", "AudioData": None, "StopAudio": {}})

    threading.Thread(target=lambda: loop.run_until_complete(el_session()), daemon=True).start()

    # Keep all ACS-socket I/O on this one thread: receive with a short timeout,
    # then drain any audio the ElevenLabs thread queued. Sending from the other
    # thread would race flask-sock and corrupt the stream.
    try:
        while True:
            raw = ws.receive(timeout=0.02)  # None when no frame arrived this tick
            if raw:
                evt = json.loads(raw)
                if evt.get("kind") == "AudioData" and el["ws"]:  # caller audio -> agent
                    asyncio.run_coroutine_threadsafe(
                        el["ws"].send(json.dumps({"user_audio_chunk": evt["audioData"]["data"]})), loop)
            while not to_acs.empty():
                ws.send(json.dumps(to_acs.get_nowait()))
    except Exception:
        pass  # ACS socket closed

Deploy to Azure Container Apps and capture the public FQDN:

Bash
az containerapp up --name acs-el-bridge --resource-group $RG \
  --source . --ingress external --target-port 8080 \
  --env-vars ELEVENLABS_AGENT_ID=$AGENT_ID \
    ELEVENLABS_ORIGIN=wss://api.elevenlabs.io

FQDN=$(az containerapp show -n acs-el-bridge -g $RG \
  --query properties.configuration.ingress.fqdn -o tsv)

Then set BRIDGE_PUBLIC_HOST=$FQDN and the ACS connection string (as a secret) on the app.

Create an Event Grid subscription on the ACS resource that posts IncomingCall to the bridge. The bridge’s validation handshake (above) completes the subscription automatically.

Bash
ACS_ID=$(az communication show -n my-acs -g $RG --query id -o tsv)
az eventgrid event-subscription create \
  --name acs-incomingcall \
  --source-resource-id "$ACS_ID" \
  --endpoint "https://$FQDN/api/incomingCall" \
  --endpoint-type webhook \
  --included-event-types Microsoft.Communication.IncomingCall

# Verify — should print "Succeeded"
az eventgrid event-subscription show --name acs-incomingcall \
  --source-resource-id "$ACS_ID" --query provisioningState -o tsv

The subscription appears under the ACS resource’s Events blade:

The Events blade of the ACS resource listing the acs-incomingcall webhook subscription filtered to Microsoft.Communication.IncomingCall

ACS resource → Events → Event Subscriptions

Dial the number — the agent answers.

  • Direct dial: a Teams user with Teams Phone + a Calling Plan can dial the ACS number like any external number.
  • Teams resource account (TPE): bind a Teams resource account to the ACS resource with Teams Phone Extensibility so calls to the resource account fire the same IncomingCall → bridge flow.

When the agent ends the conversation (e.g. its End Call tool), ElevenLabs closes the WebSocket. Hang up the ACS leg so the caller isn’t left on a dead line:

Python
CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING) \
    .get_call_connection(call_connection_id).hang_up(is_for_everyone=True)

ElevenLabs’ native transfer tools only apply when ElevenLabs owns the telephony, so here the agent fires a custom client tool (e.g. transfer_to_human) that your bridge handles by adding the human to the live call with add_participant (warm) rather than a blind transfer:

Python
conn = client.get_call_connection(call_connection_id)
conn.add_participant(
    PhoneNumberIdentifier(human_number),
    source_caller_id_number=PhoneNumberIdentifier(your_outbound_number),
    invitation_timeout=30,
)
# then mute the bot and skip the end-of-call hangup so the human's leg survives

ACS emits AddParticipantSucceeded / AddParticipantFailed callbacks to /api/callbacks. Return a client_tool_result to the agent so it can say its handoff line. See system tools for the agent-side configuration.

No IncomingCall reaches the bridge

Confirm the Event Grid subscription provisioned (provisioningState: Succeeded) and the bridge’s /api/incomingCall returned the validation echo. Confirm the number has inbound calling and is in the same ACS resource the subscription is on. On the subscription’s Filters tab, the event types must include Incoming Call:

The event subscription Filters tab with the event type filtered to Incoming Call

Event subscription → Filters → Incoming Call

CreateCallFailed / AddParticipantFailed for an international number

ACS outbound to some destinations (e.g. India) is restricted/intermittent. Use a supported destination, or front the human leg with a SIP/Operator number. The bridge logic is unaffected — it’s a carrier-level failure on the outbound leg.

Audio is distorted or wrong speed

Both sides must be PCM 16 kHz mono. Set the agent’s input/output format to pcm_16000; the bridge logs the negotiated format from conversation_initiation_metadata.

Can't buy a number / number not available in my country

Number purchase requires a paid subscription type (MCA/EA/PAYG). If ACS doesn’t offer numbers in your country, use a SIP provider instead.

Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu