Azure Communication Services

Permite que usuarios llamen a un número de teléfono al que responde tu agente de ElevenLabs mediante ACS Call Automation.

Descripción general

Este enfoque proporciona a tu agente un número de teléfono. Quien llama lo marca, Azure Communication Services (ACS) responde con streaming multimedia bidireccional, y un pequeño puente transmite audio PCM entre ACS y el agente de ElevenLabs mediante el protocolo WebSocket de agentes estándar. Es el patrón de centro de contacto/IVR, la misma arquitectura que una implementación de trunking SIP, con ACS como operador.

También se conecta a Teams de dos formas: un usuario de Teams con un plan de llamadas puede marcar directamente el número de ACS, o puedes poner delante del número Teams Phone Extensibility para que las llamadas a una cuenta de recursos de Teams se dirijan a ACS.

ACS aprovisiona números de PSTN solo en un conjunto limitado de países. Si no hay un número disponible en tu región, utiliza un proveedor SIP con trunking SIP o el bot de llamadas de Graph.

Cómo funciona

Quien llama marca el número de ACS; ACS envía IncomingCall mediante Event Grid al puente, que responde con streaming multimedia PCM 16k bidireccional y lo transmite al agente de ElevenLabs mediante un WebSocket
Llamada entrante → ACS → puente → ElevenLabs

El audio es PCM mono de 16 kHz en ambos extremos (el formato de entrada/salida del agente es pcm_16000), por lo que se transmite como base64 sin remuestreo.

El puente expone estas rutas:

RutaFinalidad
POST /api/incomingCallWebhook de Event Grid: valida la suscripción y después ejecuta answer_call con streaming multimedia
POST /api/callbacksEventos de ciclo de vida de Call Automation (CallConnected, CallDisconnected, AddParticipant*)
GET|WS /wsSocket de streaming multimedia de ACS ↔ ElevenLabs
POST /api/outboundCallOpcional: realiza una llamada saliente que conecta a quien responde con el agente

Requisitos

  1. Una suscripción de Azure de pago (MCA / EA / Pay-As-You-Go); las suscripciones gratuitas, de prueba o patrocinadas no pueden comprar números.
  2. Un recurso de Azure Communication Services.
  3. Un host HTTPS para el puente con un WebSocket público (Azure Container Apps, App Service o una VM).
  4. Un agente de ElevenLabs configurado en PCM 16000 Hz en ambos extremos: formato de salida TTS en la pestaña Voz y formato de audio de entrada de usuario en la pestaña Avanzado.

Permisos y roles

ÁmbitoRol / permisoMotivo
Azure RBACColaborador en el grupo de recursoscrear el recurso ACS, la Container App y la suscripción de Event Grid
Suscripción de AzurePropietario o colaborador en la suscripcióncomprar números de teléfono (de lo contrario, la opción de compra está desactivada)
FacturaciónTipo de suscripción MCA / EA / Pay-As-You-Golas suscripciones gratuitas, de prueba, patrocinadas y Dev no pueden comprar números

Con el rol Colaborador (no Propietario), az containerapp up no puede crear la asignación del rol de extracción de ACR de identidad administrada. Activa el usuario administrador del registro y asígnalo en su lugar; consulta la advertencia del paso 2.

Paso 1: aprovisiona el recurso y el número de ACS

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

Compra un número en el recurso (Portal → tu recurso ACS → Números de teléfono → Obtener, o el SDK de phone-numbers). Para un agente que responde llamadas, basta con un número con llamadas entrantes; añade la capacidad saliente si también quieres usar /api/outboundCall.

El panel Phone numbers del recurso ACS muestra los números activos y sus
capacidades de llamada

Números de teléfono del recurso ACS: la columna Calling muestra la dirección de cada número

Para verificarlo desde la CLI (requiere az extension add --name communication) y obtener la cadena de conexión que el puente utiliza como 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"

Paso 2: despliega el puente

El puente es una pequeña aplicación Flask + flask-sock que utiliza azure-communication-callautomation. El núcleo del flujo entrante:

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

En el socket /ws, transmite PCM16 en ambos sentidos: reenvía los frames AudioData de ACS a ElevenLabs como {"user_audio_chunk": "<base64>"} y devuelve el audio del agente como {"Kind":"AudioData","AudioData":{"Data":"<base64>"},"StopAudio":null}. El primer frame que envía ACS es AudioMetadata (el formato negociado): regístralo e ignóralo. El lado de ElevenLabs utiliza el protocolo WebSocket de agentes estándar.

ACS usa distintos formatos de mayúsculas y minúsculas en el JSON según la dirección: los frames entrantes que envía usan camelCase (kind, audioData.data), mientras que los frames salientes que espera usan PascalCase (Kind, AudioData.Data, StopAudio). Mantén ambos formatos diferenciados: la retransmisión de abajo los refleja.

bridge.py — retransmisión multimedia
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

Esta retransmisión es intencionadamente mínima. Para producción, añade registros, reconexión y una desconexión ordenada. Consulta la referencia completa de mensajes en la documentación de WebSocket.

EL_WS se conecta a un agente público. Para un agente privado, haz que el puente solicite una URL firmada de corta duración en el servidor: GET /v1/convai/conversation/get-signed-url?agent_id=... con tu clave de API, y conéctate en su lugar a la URL devuelta. Para residencia de datos, configura ELEVENLABS_ORIGIN con tu host de residencia (wss://api.eu.residency.elevenlabs.io, .in. o .sg.): las solicitudes de URL firmada usan el host https:// correspondiente.

Despliega en Azure Container Apps y captura el FQDN público:

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)

Después, configura BRIDGE_PUBLIC_HOST=$FQDN y la cadena de conexión de ACS (como secreto) en la aplicación.

Con el rol Colaborador (no Propietario), az containerapp up no puede crear el rol de extracción de ACR de identidad administrada. Activa el usuario administrador del registro (az acr update --admin-enabled true) y asígnalo con az containerapp registry set; después ejecuta az containerapp update --image ....

Paso 3: dirige IncomingCall al puente

Crea una suscripción de Event Grid en el recurso ACS que publique IncomingCall en el puente. El protocolo de validación del puente (anterior) completa la suscripción automáticamente.

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 suscripción aparece en el panel Events del recurso ACS:

El panel Events del recurso ACS muestra la suscripción de webhook acs-incomingcall filtrada
por
Microsoft.Communication.IncomingCall

Recurso ACS → Events → Event Subscriptions

Marca el número: el agente responderá.

Conexión con Teams

  • Marcación directa: un usuario de Teams con Teams Phone y un plan de llamadas puede marcar el número de ACS como cualquier número externo.
  • Cuenta de recursos de Teams (TPE): vincula una cuenta de recursos de Teams al recurso ACS con Teams Phone Extensibility para que las llamadas a la cuenta de recursos activen el mismo flujo IncomingCall → puente.

Fin de la llamada

Cuando el agente termina la conversación (por ejemplo, mediante su herramienta Finalizar llamada), ElevenLabs cierra el WebSocket. Cuelga el extremo de ACS para que quien llama no quede en una línea muerta:

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

Transferencia asistida a una persona

Las herramientas de transferencia nativas de ElevenLabs solo se aplican cuando ElevenLabs gestiona la telefonía, así que aquí el agente activa una herramienta de cliente personalizada (por ejemplo, transfer_to_human) que tu puente gestiona añadiendo a la persona a la llamada activa con add_participant (asistida), en lugar de realizar una transferencia ciega:

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 envía devoluciones de llamada AddParticipantSucceeded / AddParticipantFailed a /api/callbacks. Devuelve un client_tool_result al agente para que pueda decir su frase de transferencia. Consulta las herramientas del sistema para configurar el lado del agente.

Configura la protección de transferencia en el momento en que se active la herramienta (antes de llamar a add_participant), o un cierre rápido del WebSocket de EL podría competir con el cuelgue y finalizar la llamada antes de que se una la persona.

Solución de problemas

Confirma que la suscripción de Event Grid se aprovisionó (provisioningState: Succeeded) y que /api/incomingCall del puente devolvió el eco de validación. Confirma que el número tiene llamadas entrantes y está en el mismo recurso ACS en el que se encuentra la suscripción. En la pestaña Filters de la suscripción, los tipos de evento deben incluir Incoming Call:

La pestaña Filters de la suscripción de evento, con el tipo de evento filtrado por Incoming
Call

Suscripción de evento → Filters → Incoming Call

Las llamadas salientes de ACS a algunos destinos (por ejemplo, India) tienen restricciones o son intermitentes. Utiliza un destino compatible o conecta el extremo de la persona mediante un número SIP/de operador. La lógica del puente no se ve afectada: es un fallo a nivel de operador en el extremo saliente.

Ambos lados deben ser PCM mono de 16 kHz. Configura el formato de entrada/salida del agente en pcm_16000; el puente registra el formato negociado desde conversation_initiation_metadata.

La compra de números requiere un tipo de suscripción de pago (MCA/EA/PAYG). Si ACS no ofrece números en tu país, utiliza un proveedor SIP.

Enlaces útiles