Vai alla navigazione

Azure Communication Services

Consenti agli utenti di chiamare un numero di telefono a cui risponde il tuo agente ElevenLabs, tramite ACS Call Automation.

Panoramica

Questo approccio assegna al tuo agente un numero di telefono. Un chiamante lo compone, Azure Communication Services (ACS) risponde con streaming multimediale bidirezionale e un piccolo bridge inoltra l’audio PCM tra ACS e l’agente ElevenLabs usando il protocollo WebSocket dell’agente standard. È il modello di contact center/IVR, analogo a un’implementazione con SIP trunking, con ACS come operatore.

Si connette inoltre a Teams in due modi: un utente Teams con un Calling Plan può chiamare direttamente il numero ACS, oppure puoi esporre il numero tramite Teams Phone Extensibility affinché le chiamate a un account risorsa Teams vengano instradate ad ACS.

ACS fornisce numeri PSTN solo in un numero limitato di paesi. Se nella tua area geografica non è disponibile un numero, usa invece un provider SIP con SIP trunking, oppure il bot di chiamata Graph.

Come funziona

Un chiamante compone il numero ACS; ACS attiva IncomingCall tramite Event Grid verso il bridge, che risponde con streaming multimediale PCM 16k bidirezionale e lo inoltra all'agente ElevenLabs tramite WebSocket
Chiamata in entrata → ACS → bridge → ElevenLabs

L’audio è PCM 16 kHz mono su entrambi i lati (il formato di input/output dell’agente è pcm_16000), quindi viene trasferito come base64 senza ricampionamento.

Il bridge espone queste route:

RouteScopo
POST /api/incomingCallWebhook Event Grid: convalida la sottoscrizione, quindi esegue answer_call con streaming multimediale
POST /api/callbacksEventi del ciclo di vita di Call Automation (CallConnected, CallDisconnected, AddParticipant*)
GET|WS /wsSocket di streaming multimediale ACS ↔ ElevenLabs
POST /api/outboundCallOpzionale: effettua una chiamata in uscita che collega chi risponde all’agente

Requisiti

  1. Una sottoscrizione Azure a pagamento (MCA / EA / Pay-As-You-Go): le sottoscrizioni gratuite, di prova o sponsorizzate non possono acquistare numeri.
  2. Una risorsa Azure Communication Services.
  3. Un host HTTPS per il bridge con un WebSocket pubblico (Azure Container Apps, App Service o una VM).
  4. Un agente ElevenLabs impostato su PCM 16000 Hz su entrambi i lati: formato di output TTS nella scheda Voce, formato audio di input dell’utente nella scheda Avanzate.

Autorizzazioni e ruoli

AmbitoRuolo / autorizzazioneMotivo
Azure RBACContributor nel gruppo di risorsecreare la risorsa ACS, la Container App e la sottoscrizione Event Grid
Sottoscrizione AzureOwner o Contributor nella sottoscrizioneacquistare numeri di telefono (altrimenti l’opzione di acquisto è disabilitata)
FatturazioneTipo di sottoscrizione MCA / EA / Pay-As-You-Gole sottoscrizioni gratuite, di prova, sponsorizzate e Dev non possono acquistare numeri

Con il ruolo Contributor (non Owner), az containerapp up non può creare l’assegnazione del ruolo ACR pull per l’identità gestita. Abilita invece l’utente amministratore del registro e collegalo: vedi l’avviso nel passaggio 2.

Passaggio 1 — Esegui il provisioning della risorsa ACS e del numero

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

Acquista un numero nella risorsa (Portale → risorsa ACS → Numeri di telefono → Ottieni, oppure usa l’SDK phone-numbers). Per un agente che risponde alle chiamate, è sufficiente un numero con chiamate in entrata; aggiungi la funzionalità in uscita se vuoi usare anche /api/outboundCall.

Il pannello Phone numbers della risorsa ACS che elenca i numeri attivi con le relative funzionalità di chiamata

Numeri di telefono nella risorsa ACS: la colonna Calling mostra la direzione di ogni numero

Per verificare dalla CLI (richiede az extension add --name communication) e recuperare la stringa di connessione che il bridge usa come 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"

Passaggio 2 — Distribuisci il bridge

Il bridge è una piccola app Flask + flask-sock che usa azure-communication-callautomation. Ecco il nucleo del flusso in entrata:

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

Nel socket /ws, inoltra PCM16 in entrambe le direzioni: invia i frame ACS AudioData a ElevenLabs come {"user_audio_chunk": "<base64>"} e rimanda l’audio dell’agente come {"Kind":"AudioData","AudioData":{"Data":"<base64>"},"StopAudio":null}. Il primo frame inviato da ACS è AudioMetadata (il formato negoziato): registralo e ignoralo. Il lato ElevenLabs usa il protocollo WebSocket dell’agente standard.

ACS usa maiuscole/minuscole JSON diverse per ciascuna direzione: i frame in entrata che invia sono in camelCase (kind, audioData.data), mentre i frame in uscita che si aspetta sono in PascalCase (Kind, AudioData.Data, StopAudio). Mantieni distinti i due formati: il relay seguente rispecchia questa differenza.

bridge.py — relay multimediale
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

Questo relay è volutamente essenziale. Per la produzione, aggiungi logging, riconnessione e chiusura pulita. Il riferimento completo dei messaggi è nella documentazione WebSocket.

EL_WS si connette a un agente pubblico. Per un agente privato, fai in modo che il bridge richieda lato server un URL firmato di breve durata: GET /v1/convai/conversation/get-signed-url?agent_id=... con la tua chiave API, quindi connettiti all’URL restituito. Per la residenza dei dati, imposta ELEVENLABS_ORIGIN sull’host della tua area di residenza (wss://api.eu.residency.elevenlabs.io, .in. o .sg.): le richieste di URL firmati usano l’host https:// corrispondente.

Distribuisci su Azure Container Apps e acquisisci l’FQDN pubblico:

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)

Quindi imposta BRIDGE_PUBLIC_HOST=$FQDN e la stringa di connessione ACS (come secret) nell’app.

Con il ruolo Contributor (non Owner), az containerapp up non può creare il ruolo ACR pull dell’identità gestita. Abilita l’utente amministratore del registro (az acr update --admin-enabled true) e collegalo con az containerapp registry set, quindi esegui az containerapp update --image ....

Passaggio 3 — Instrada IncomingCall al bridge

Crea una sottoscrizione Event Grid sulla risorsa ACS che invii IncomingCall al bridge. L’handshake di convalida del bridge (sopra) completa automaticamente la sottoscrizione.

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

La sottoscrizione appare nel pannello Events della risorsa ACS:

Il pannello Events della risorsa ACS che elenca la sottoscrizione webhook acs-incomingcall filtrata su Microsoft.Communication.IncomingCall

Risorsa ACS → Events → Event Subscriptions

Chiama il numero: risponderà l’agente.

Collegamento a Teams

  • Chiamata diretta: un utente Teams con Teams Phone + un Calling Plan può chiamare il numero ACS come qualsiasi numero esterno.
  • Account risorsa Teams (TPE): collega un account risorsa Teams alla risorsa ACS con Teams Phone Extensibility, affinché le chiamate all’account risorsa attivino lo stesso flusso IncomingCall → bridge.

Fine della chiamata

Quando l’agente termina la conversazione (ad esempio con lo strumento End Call), ElevenLabs chiude il WebSocket. Riaggancia il lato ACS affinché il chiamante non resti su una linea inattiva:

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

Trasferimento assistito a un operatore

Gli strumenti di trasferimento nativi di ElevenLabs si applicano solo quando ElevenLabs gestisce la telefonia. In questo caso, quindi, l’agente attiva uno strumento client personalizzato (ad esempio transfer_to_human) che il bridge gestisce aggiungendo l’operatore alla chiamata in corso con add_participant (trasferimento assistito), anziché effettuare un trasferimento cieco:

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 invia i callback AddParticipantSucceeded / AddParticipantFailed a /api/callbacks. Restituisci un client_tool_result all’agente, così potrà pronunciare il messaggio di passaggio. Consulta gli strumenti di sistema per la configurazione lato agente.

Imposta la protezione del trasferimento non appena si attiva lo strumento (prima di chiamare add_participant), altrimenti una rapida chiusura del WebSocket EL può entrare in conflitto con il riaggancio e interrompere la chiamata prima che l’operatore si unisca.

Risoluzione dei problemi

Verifica che sia stato effettuato il provisioning della sottoscrizione Event Grid (provisioningState: Succeeded) e che /api/incomingCall del bridge abbia restituito l’eco di convalida. Verifica che il numero supporti le chiamate in entrata e appartenga alla stessa risorsa ACS su cui è presente la sottoscrizione. Nella scheda Filters della sottoscrizione, i tipi di evento devono includere Incoming Call:

La scheda Filters della sottoscrizione evento con il tipo di evento filtrato su Incoming Call

Sottoscrizione evento → Filters → Incoming Call

Le chiamate in uscita ACS verso alcune destinazioni (ad esempio l’India) sono limitate o intermittenti. Usa una destinazione supportata oppure anteponi al lato dell’operatore un numero SIP/Operator. La logica del bridge non cambia: si tratta di un errore a livello di operatore sul lato della chiamata in uscita.

Entrambi i lati devono usare PCM 16 kHz mono. Imposta il formato di input/output dell’agente su pcm_16000; il bridge registra il formato negoziato da conversation_initiation_metadata.

L’acquisto di numeri richiede un tipo di sottoscrizione a pagamento (MCA/EA/PAYG). Se ACS non offre numeri nel tuo Paese, usa invece un provider SIP.