Azure Communication Services

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

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.

ACS provisions PSTN numbers only in a limited set of countries. If a number isn’t available in your region, use a SIP provider with SIP trunking instead, or the Graph calling bot.

How it works

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:

RoutePurpose
POST /api/incomingCallEvent Grid webhook: validates the subscription, then answer_call with media streaming
POST /api/callbacksCall Automation lifecycle events (CallConnected, CallDisconnected, AddParticipant*)
GET|WS /wsACS media-streaming socket ↔ ElevenLabs
POST /api/outboundCallOptional: place an outbound call that connects the answerer to the agent

Requirements

  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.

Permissions & roles

ScopeRole / permissionWhy
Azure RBACContributor on the resource groupcreate the ACS resource, Container App, and Event Grid subscription
Azure subscriptionOwner or Contributor on the subscriptionpurchase phone numbers (the buy option is disabled otherwise)
BillingMCA / EA / Pay-As-You-Go subscription typefree, trial, sponsorship, and Dev subscriptions cannot buy numbers

Under Contributor (not Owner), az containerapp up can’t create the managed-identity ACR pull role assignment. Enable the registry admin user and attach it instead — see the warning in Step 2.

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 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:

$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

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

bridge.py (excerpt)
1from azure.communication.callautomation import (
2 CallAutomationClient, MediaStreamingOptions, StreamingTransportType,
3 MediaStreamingContentType, MediaStreamingAudioChannelType, AudioFormat,
4)
5
6@app.route("/api/incomingCall", methods=["POST"])
7def incoming_call():
8 for event in request.get_json():
9 # Event Grid subscription validation handshake
10 if event.get("eventType") == "Microsoft.EventGrid.SubscriptionValidationEvent":
11 return jsonify({"validationResponse": event["data"]["validationCode"]})
12
13 if event.get("eventType") == "Microsoft.Communication.IncomingCall":
14 client = CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING)
15 client.answer_call(
16 incoming_call_context=event["data"]["incomingCallContext"],
17 callback_url=f"https://{HOST}/api/callbacks",
18 media_streaming=MediaStreamingOptions(
19 transport_url=f"wss://{HOST}/ws",
20 transport_type=StreamingTransportType.WEBSOCKET,
21 content_type=MediaStreamingContentType.AUDIO,
22 audio_channel_type=MediaStreamingAudioChannelType.MIXED,
23 start_media_streaming=True,
24 enable_bidirectional=True,
25 audio_format=AudioFormat.PCM16_K_MONO,
26 ),
27 )
28 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.

ACS uses different JSON casing per direction: inbound frames it sends are camelCase (kind, audioData.data), while outbound frames it expects are PascalCase (Kind, AudioData.Data, StopAudio). Keep the two cases distinct — the relay below mirrors this.

bridge.py — media relay
1import asyncio, json, os, queue, threading, websockets
2from flask_sock import Sock
3
4sock = Sock(app)
5AGENT_ID = os.environ["ELEVENLABS_AGENT_ID"]
6# US default; data residency: wss://api.eu.residency.elevenlabs.io, .in., or .sg.
7EL_ORIGIN = os.environ.get("ELEVENLABS_ORIGIN", "wss://api.elevenlabs.io")
8EL_WS = f"{EL_ORIGIN}/v1/convai/conversation?agent_id={AGENT_ID}"
9
10@sock.route("/ws")
11def media_stream(ws):
12 loop = asyncio.new_event_loop()
13 el = {"ws": None}
14 to_acs = queue.Queue() # outbound frames; only this handler thread touches `ws`
15
16 async def el_session():
17 async with websockets.connect(EL_WS) as elws:
18 el["ws"] = elws
19 await elws.send(json.dumps({"type": "conversation_initiation_client_data"}))
20 async for msg in elws:
21 data = json.loads(msg)
22 kind = data.get("type")
23 if kind == "audio": # agent audio -> caller
24 b64 = data["audio_event"]["audio_base_64"]
25 to_acs.put({"Kind": "AudioData", "AudioData": {"Data": b64}, "StopAudio": None})
26 elif kind == "ping":
27 await elws.send(json.dumps({"type": "pong", "event_id": data["ping_event"]["event_id"]}))
28 elif kind == "interruption": # barge-in
29 to_acs.put({"Kind": "StopAudio", "AudioData": None, "StopAudio": {}})
30
31 threading.Thread(target=lambda: loop.run_until_complete(el_session()), daemon=True).start()
32
33 # Keep all ACS-socket I/O on this one thread: receive with a short timeout,
34 # then drain any audio the ElevenLabs thread queued. Sending from the other
35 # thread would race flask-sock and corrupt the stream.
36 try:
37 while True:
38 raw = ws.receive(timeout=0.02) # None when no frame arrived this tick
39 if raw:
40 evt = json.loads(raw)
41 if evt.get("kind") == "AudioData" and el["ws"]: # caller audio -> agent
42 asyncio.run_coroutine_threadsafe(
43 el["ws"].send(json.dumps({"user_audio_chunk": evt["audioData"]["data"]})), loop)
44 while not to_acs.empty():
45 ws.send(json.dumps(to_acs.get_nowait()))
46 except Exception:
47 pass # ACS socket closed

This relay is intentionally minimal. For production, add logging, reconnection, and graceful teardown. The full message reference is in the WebSocket docs.

EL_WS connects to a public agent. For a private agent, have the bridge request a short-lived signed URL server-side — GET /v1/convai/conversation/get-signed-url?agent_id=... with your API key — and connect to the returned URL instead. On data residency, set ELEVENLABS_ORIGIN to your residency host (wss://api.eu.residency.elevenlabs.io, .in., or .sg.) — signed-URL requests use the matching https:// host.

Deploy 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.

Under Contributor (not Owner), az containerapp up cannot create the managed-identity ACR pull role. Enable the registry admin user (az acr update --admin-enabled true) and attach it with az containerapp registry set, then az containerapp update --image ....

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 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.

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

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:

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

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:

1conn = client.get_call_connection(call_connection_id)
2conn.add_participant(
3 PhoneNumberIdentifier(human_number),
4 source_caller_id_number=PhoneNumberIdentifier(your_outbound_number),
5 invitation_timeout=30,
6)
7# 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.

Set the transfer guard the instant the tool fires (before calling add_participant), or a fast EL WebSocket close can race the hangup and drop the call before the human joins.

Troubleshooting

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

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.

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.

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