Azure Communication Services

Permettez aux utilisateurs d’appeler un numéro de téléphone auquel répond votre agent ElevenLabs, via ACS Call Automation.

Présentation

Cette approche attribue un numéro de téléphone à votre agent. Un appelant le compose, Azure Communication Services (ACS) répond avec un streaming multimédia bidirectionnel, puis un petit pont relaie l’audio PCM entre ACS et l’agent ElevenLabs à l’aide du protocole WebSocket d’agent standard. Il s’agit du modèle de centre de contact / IVR, similaire à un déploiement de SIP trunking, avec ACS comme opérateur.

Cette solution se connecte également à Teams de deux façons : un utilisateur Teams disposant d’un Calling Plan peut appeler directement le numéro ACS, ou vous pouvez placer Teams Phone Extensibility devant ce numéro afin que les appels vers un compte de ressource Teams soient acheminés vers ACS.

ACS provisionne des numéros PSTN uniquement dans un ensemble limité de pays. Si aucun numéro n’est disponible dans votre région, utilisez plutôt un fournisseur SIP avec le SIP trunking, ou le bot d’appel Graph.

Fonctionnement

Un appelant compose le numéro ACS ; ACS envoie IncomingCall via Event Grid au pont, qui répond avec un streaming multimédia PCM 16k bidirectionnel et le relaie à l'agent ElevenLabs via un WebSocket
Appel entrant → ACS → pont → ElevenLabs

L’audio est en PCM 16 kHz mono sur les deux liaisons, le format d’entrée/sortie de l’agent étant pcm_16000, il est donc transmis en base64 sans rééchantillonnage.

Le pont expose les routes suivantes :

RouteObjectif
POST /api/incomingCallWebhook Event Grid : valide l’abonnement, puis lance answer_call avec streaming multimédia
POST /api/callbacksÉvénements du cycle de vie Call Automation (CallConnected, CallDisconnected, AddParticipant*)
GET|WS /wsSocket de streaming multimédia ACS ↔ ElevenLabs
POST /api/outboundCallFacultatif : passe un appel sortant qui connecte le répondant à l’agent

Prérequis

  1. Un abonnement Azure payant (MCA / EA / Pay-As-You-Go), les abonnements gratuits, d’essai ou sponsorisés ne peuvent pas acheter de numéros.
  2. Une ressource Azure Communication Services.
  3. Un hôte HTTPS pour le pont avec un WebSocket public, Azure Container Apps, App Service ou une VM.
  4. Un agent ElevenLabs configuré en PCM 16000 Hz sur les deux liaisons : format de sortie TTS dans l’onglet Voice, format audio d’entrée utilisateur dans l’onglet Advanced.

Autorisations et rôles

ÉtendueRôle / autorisationMotif
Azure RBACContributor sur le groupe de ressourcescréer la ressource ACS, l’application conteneur et l’abonnement Event Grid
Abonnement AzureOwner ou Contributor sur l’abonnementacheter des numéros de téléphone, l’option d’achat est sinon désactivée
FacturationType d’abonnement MCA / EA / Pay-As-You-Goles abonnements gratuits, d’essai, sponsorisés et Dev ne peuvent pas acheter de numéros

Avec le rôle Contributor, et non Owner, az containerapp up ne peut pas créer l’attribution de rôle ACR pull pour l’identité gérée. Activez plutôt l’utilisateur administrateur du registre et attachez-le, consultez l’avertissement de l’étape 2.

Étape 1 : provisionnez la ressource ACS et le numéro

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

Achetez un numéro dans la ressource, Portal → votre ressource ACS → Phone numbers → Get, ou utilisez le SDK phone-numbers. Pour un agent qui répond aux appels, un numéro avec les appels entrants suffit ; ajoutez la capacité sortante si vous souhaitez également utiliser /api/outboundCall.

Le panneau Phone numbers de la ressource ACS répertoriant les numéros actifs avec leurs capacités d'appel

Numéros de téléphone de la ressource ACS, la colonne Calling indique la direction de chaque numéro

Pour vérifier depuis la CLI, nécessite az extension add --name communication, et récupérer la chaîne de connexion que le pont utilise comme 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"

Étape 2 : déployez le pont

Le pont est une petite application Flask + flask-sock utilisant azure-communication-callautomation. Voici le cœur du flux entrant :

bridge.py (extrait)
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"})

Sur le socket /ws, relayez PCM16 dans les deux sens : transférez les frames ACS AudioData à ElevenLabs sous la forme {"user_audio_chunk": "<base64>"}, puis renvoyez l’audio de l’agent sous la forme {"Kind":"AudioData","AudioData":{"Data":"<base64>"},"StopAudio":null}. La première frame envoyée par ACS est AudioMetadata, le format négocié, enregistrez-la dans les logs et ignorez-la. Le côté ElevenLabs utilise le protocole WebSocket d’agent standard.

ACS utilise une casse JSON différente dans chaque sens : les frames entrantes qu’il envoie sont en camelCase (kind, audioData.data), tandis que les frames sortantes qu’il attend sont en PascalCase (Kind, AudioData.Data, StopAudio). Conservez ces deux casses distinctes, le relais ci-dessous les reproduit.

bridge.py : relais multimédia
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

Ce relais est volontairement minimal. Pour la production, ajoutez la journalisation, la reconnexion et une fermeture propre. La référence complète des messages se trouve dans la documentation WebSocket.

EL_WS se connecte à un agent public. Pour un agent privé, demandez au pont une URL signée de courte durée côté serveur, GET /v1/convai/conversation/get-signed-url?agent_id=... avec votre clé API, puis connectez-vous plutôt à l’URL renvoyée. Pour la résidence des données, définissez ELEVENLABS_ORIGIN sur votre hôte de résidence (wss://api.eu.residency.elevenlabs.io, .in. ou .sg.), les requêtes d’URL signée utilisent l’hôte https:// correspondant.

Déployez vers Azure Container Apps et récupérez le FQDN public :

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)

Définissez ensuite BRIDGE_PUBLIC_HOST=$FQDN et la chaîne de connexion ACS, comme secret, dans l’application.

Avec le rôle Contributor, et non Owner, az containerapp up ne peut pas créer le rôle ACR pull pour l’identité gérée. Activez l’utilisateur administrateur du registre (az acr update --admin-enabled true) et attachez-le avec az containerapp registry set, puis utilisez az containerapp update --image ....

Étape 3 : acheminez IncomingCall vers le pont

Créez un abonnement Event Grid sur la ressource ACS qui publie IncomingCall vers le pont. La négociation de validation du pont, ci-dessus, finalise automatiquement l’abonnement.

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

L’abonnement apparaît dans le panneau Events de la ressource ACS :

Le panneau Events de la ressource ACS affichant l'abonnement webhook acs-incomingcall filtré sur Microsoft.Communication.IncomingCall

Ressource ACS → Events → Event Subscriptions

Composez le numéro, l’agent répond.

Connexion à Teams

  • Appel direct : un utilisateur Teams avec Teams Phone + un Calling Plan peut appeler le numéro ACS comme n’importe quel numéro externe.
  • Compte de ressource Teams (TPE) : liez un compte de ressource Teams à la ressource ACS avec Teams Phone Extensibility afin que les appels vers le compte de ressource déclenchent le même flux IncomingCall → pont.

Fin d’appel

Lorsque l’agent termine la conversation, par exemple avec son outil End Call, ElevenLabs ferme le WebSocket. Raccrochez la liaison ACS afin que l’appelant ne reste pas sur une ligne inactive :

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

Transfert assisté vers un humain

Les outils de transfert natifs d’ElevenLabs s’appliquent uniquement lorsqu’ElevenLabs gère la téléphonie. Ici, l’agent déclenche donc un outil client personnalisé, par exemple transfer_to_human, que votre pont gère en ajoutant la personne à l’appel en cours avec add_participant, transfert assisté, plutôt qu’en effectuant un transfert aveugle :

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 envoie les callbacks AddParticipantSucceeded / AddParticipantFailed à /api/callbacks. Renvoyez un client_tool_result à l’agent afin qu’il puisse prononcer sa phrase de transfert. Consultez les outils système pour la configuration côté agent.

Définissez la protection de transfert dès le déclenchement de l’outil, avant d’appeler add_participant, sinon une fermeture rapide du WebSocket EL peut entrer en concurrence avec le raccrochage et interrompre l’appel avant que la personne ne rejoigne la conversation.

Résolution des problèmes

Vérifiez que l’abonnement Event Grid a été provisionné (provisioningState: Succeeded) et que le /api/incomingCall du pont a renvoyé l’écho de validation. Vérifiez que le numéro accepte les appels entrants et appartient à la même ressource ACS que l’abonnement. Dans l’onglet Filters de l’abonnement, les types d’événements doivent inclure Incoming Call :

L'onglet Filters de l'abonnement aux événements avec le type d'événement filtré sur Incoming Call

Abonnement aux événements → Filters → Incoming Call

Les appels sortants ACS vers certaines destinations, par exemple l’Inde, sont restreints ou intermittents. Utilisez une destination prise en charge, ou faites passer la liaison humaine par un numéro SIP/Operator. La logique du pont n’est pas affectée, il s’agit d’un échec au niveau de l’opérateur sur la liaison sortante.

Les deux côtés doivent être en PCM 16 kHz mono. Configurez le format d’entrée/sortie de l’agent sur pcm_16000 ; le pont enregistre dans les logs le format négocié depuis conversation_initiation_metadata.

L’achat de numéros nécessite un type d’abonnement payant, MCA/EA/PAYG. Si ACS ne propose pas de numéros dans votre pays, utilisez plutôt un fournisseur SIP.

Liens utiles