Azure Communication Services
Let users dial a phone number that your ElevenLabs agent answers, via ACS Call Automation.
Overview
Section titled “Overview”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.
How it works
Section titled “How it works”
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 |
Requirements
Section titled “Requirements”- A paid Azure subscription (MCA / EA / Pay-As-You-Go) — free/trial/sponsorship subs cannot buy numbers.
- An Azure Communication Services resource.
- An HTTPS host for the bridge with a public WebSocket (Azure Container Apps, App Service, or a VM).
- 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.
Permissions & roles
Section titled “Permissions & roles”| 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”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 unitedstatesBuy 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.
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:
CONN=$(az communication list-key -n my-acs -g $RG --query primaryConnectionString -o tsv)
az communication phonenumber list --connection-string "$CONN" --query "[].phoneNumber"Step 2 — Deploy the bridge
Section titled “Step 2 — Deploy the bridge”The bridge is a small Flask + flask-sock app using azure-communication-callautomation. The core of the inbound flow:
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.
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 closedDeploy to Azure Container Apps and capture the public FQDN:
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.
Step 3 — Route IncomingCall to the bridge
Section titled “Step 3 — Route IncomingCall to the bridge”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.
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 tsvThe subscription appears under the ACS resource’s Events blade:
ACS resource → Events → Event Subscriptions
Dial the number — the agent answers.
Connecting it to Teams
Section titled “Connecting it to Teams”- 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.
End of call
Section titled “End of call”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:
CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING) \
.get_call_connection(call_connection_id).hang_up(is_for_everyone=True)Warm transfer to a human
Section titled “Warm transfer to a human”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:
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 survivesACS 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.
Troubleshooting
Section titled “Troubleshooting”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:

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.