Bot de llamadas de Graph

Llama o chatea con tu agente de ElevenLabs por su nombre dentro de Microsoft Teams, como si fuera un compañero.

Descripción general

Este enfoque convierte al agente en una identidad de Teams a la que se puede llamar. Un usuario lo busca por nombre y lo llama de forma individual, y el agente responde en tiempo real, sin número de teléfono, PSTN ni créditos de comunicaciones. Es el único enfoque al que se puede llamar por nombre y el más complejo de ejecutar.

Usa un bot de medios en tiempo real de Microsoft Graph (la plataforma de llamadas de Cloud Communications). El SDK de medios (Microsoft.Skype.Bots.Media) funciona solo con .NET en Windows Server; no hay una opción para Linux ni fuera de .NET para el audio sin procesar en llamadas de Teams.

Este es el único enfoque al que se puede llamar por nombre dentro de Teams. Te recomendamos la pestaña de widget para una configuración más sencilla, o ACS si específicamente quieres un número de teléfono.

Cómo funciona

Un usuario de Teams llama al bot por su nombre; Teams enruta la llamada al bot de medios en una VM de Windows, que conecta audio PCM 16k sin procesar con el agente de ElevenLabs a través de un WebSocket
Llamada por nombre → bot de medios → ElevenLabs

El bot responde con medios alojados por la aplicación, recibe 50 tramas de audio por segundo (PCM de 16 kHz y 20 ms), las conecta con el agente de ElevenLabs mediante un WebSocket y transmite el audio del agente de vuelta a la llamada.

Requisitos

  1. Un registro y una aplicación de Azure Bot (registro de aplicación de Entra).
  2. Permisos de aplicación de Graph con consentimiento de administrador: Calls.AccessMedia.All (medios sin procesar) y Calls.Initiate.All.
  3. Una VM de Windows Server (≥ 2 núcleos físicos; por ejemplo, Standard_D4s_v3) con una IP pública y puertos de medios abiertos.
  4. Un certificado TLS firmado por una CA en un FQDN público para el endpoint de medios/señalización (la plataforma de medios rechaza los certificados autofirmados).
  5. Un agente de ElevenLabs configurado en PCM 16000 Hz en ambos lados: formato de salida de TTS en la pestaña Voz y formato de audio de entrada de usuario en la pestaña Avanzado.

Un D2s_v3 (2 vCPU = 1 núcleo físico) falla con MediaPlatform needs a system with at least 2 cores. Usa un tamaño con ≥ 2 núcleos físicos (por ejemplo, D4s_v3).

Permisos y roles

ÁmbitoRol / permisoMotivo
EntraAdministrador de aplicacionescrear el registro de aplicación + Azure Bot
EntraAdministrador global / Administrador de roles con privilegiosconceder el consentimiento de administrador para los permisos de llamadas de Graph; los permisos de aplicación no pueden autorizarse automáticamente
Microsoft Graph (aplicación)Calls.AccessMedia.All, Calls.Initiate.Allresponder llamadas individuales y acceder a medios sin procesar
Azure RBACColaborador en el grupo de recursoscrear la VM de Windows + Azure Bot
Administrador de Teamspermitir la carga de aplicaciones personalizadas; activar el canal Llamadas del botcargar la aplicación de forma local y recibir llamadas

Paso 1: registrar el bot y los permisos de Graph

Crea un registro de aplicación y un Azure Bot asociado a él; después, concede y autoriza los permisos de llamadas (necesitas ser administrador global o administrador de roles con privilegios para dar el consentimiento):

APPID=$(az ad app create --display-name "ElevenLabs Teams Agent" \
--sign-in-audience AzureADMyOrg --query appId -o tsv)
az ad sp create --id "$APPID"
# create a client secret and record it
az ad app credential reset --id "$APPID" --display-name bot --query password -o tsv
# Azure Bot bound to the app
az bot create --resource-group $RG --name el-teams-agent-bot \
--app-type SingleTenant --appid "$APPID" --tenant-id $TENANT \
--endpoint "https://YOUR_FQDN/api/messages" --sku S1

Concede los dos roles de aplicación de Graph y el consentimiento de administrador (requiere administrador global o administrador de roles con privilegios), y confirma después que se hayan asignado:

# Graph app roles: Calls.AccessMedia.All, Calls.Initiate.All
az ad app permission add --id "$APPID" --api 00000003-0000-0000-c000-000000000000 \
--api-permissions a7a681dc-756e-4909-b988-f160edc6655f=Role \
284383ee-7f6e-4e40-a2a8-e85dcb029101=Role
az ad app permission admin-consent --id "$APPID"
# Verify — should print both role ids
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$APPID')/appRoleAssignments" \
--query "value[].appRoleId" -o tsv

Si admin-consent devuelve Consent validation failed, concede los roles de aplicación directamente en la entidad de servicio:

GRAPH_SP=$(az ad sp show --id 00000003-0000-0000-c000-000000000000 --query id -o tsv)
BOT_SP=$(az ad sp show --id "$APPID" --query id -o tsv)
for ROLE in a7a681dc-756e-4909-b988-f160edc6655f 284383ee-7f6e-4e40-a2a8-e85dcb029101; do
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/$GRAPH_SP/appRoleAssignedTo" \
--body "{\"principalId\": \"$BOT_SP\", \"resourceId\": \"$GRAPH_SP\", \"appRoleId\": \"$ROLE\"}"
done

En el portal, verifica en el centro de administración de Entra, en Registros de aplicaciones → tu aplicación → Permisos de API, que ambos permisos muestren Concedido con marcas de verificación verdes.

La sección de permisos de API del registro de aplicación muestra Calls.AccessMedia.All y Calls.Initiate.All
concedidos

Registro de aplicación → permisos de API tras el consentimiento de administrador

Paso 2: aprovisionar la VM de Windows, el certificado y los puertos

az vm create -g $RG -n teams-media-bot --image Win2022Datacenter \
--size Standard_D4s_v3 --admin-username azureuser --admin-password '<strong-pw>' \
--public-ip-sku Standard --public-ip-address-dns-name elevenmediabot
az vm open-port -g $RG -n teams-media-bot --port 80,443,8445,9441 --priority 300

En la VM (el código nativo de la plataforma de medios los necesita; Windows Server no los incluye de forma predeterminada):

# VC++ runtime + Media Foundation feature (required by NativeMedia.dll)
choco install -y vcredist140
Install-WindowsFeature Server-Media-Foundation
# CA cert for the VM's FQDN via win-acme (HTTP-01), then import to LocalMachine\My
& wacs.exe --target manual --host <vm-fqdn>.cloudapp.azure.com `
--validation selfhosting --store pfxfile --pfxfilepath C:\bot\certs --accepttos

Abre los mismos puertos en el firewall de Windows y anota la huella digital del certificado: el bot vincula Kestrel (443 y un puerto de notificaciones) y la plataforma de medios (8445) a él.

El FQDN *.cloudapp.azure.com de la propia VM funciona con un certificado de Let’s Encrypt; no necesitas un dominio independiente.

Paso 3 — Compila y ejecuta el bot

Empieza con microsoft-graph-comms-samples PublicSamples/EchoBot de Microsoft: utiliza net6.0 y se compila con el SDK de .NET (no necesitas Visual Studio Build Tools):

git clone --depth 1 https://github.com/microsoftgraph/microsoft-graph-comms-samples.git C:\bot\samples
cd C:\bot\samples\Samples\PublicSamples\EchoBot\src
dotnet build EchoBot.sln -c Release

Configura la sección AppSettings de appsettings.json con tu AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (el FQDN de la VM), CertificateThumbprint y los puertos (llamadas 443, notificaciones 9441, contenido multimedia 8445). Añade dos ajustes para el puente de ElevenLabs que aparece a continuación: ElevenLabsAgentId y ElevenLabsOrigin (wss://api.elevenlabs.io o tu host de residencia). Ejecútalo como una tarea programada o servicio de Windows para que sobreviva a los reinicios.

El límite de tiempo de ejecución predeterminado (72 horas) del Programador de tareas finaliza silenciosamente las tareas de larga duración: un bot iniciado al arrancar deja de funcionar tres días después y las llamadas fallan con “we couldn’t connect you”. Desactiva el límite y añade el reinicio en caso de error:

$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -StartWhenAvailable
Set-ScheduledTask -TaskName EchoBot -Settings $s

El EchoBot predeterminado se bloquea en una llamada realizada al puerto estándar 443: HttpHelpers.SetAbsoluteUri llama a req.Host.Port.Value, que es nulo cuando la cabecera Host no tiene un puerto explícito. Cámbialo por req.Host.Port ?? (req.IsHttps ? 443 : 80).

Sustituye el eco por ElevenLabs

La interfaz de audio de EchoBot es sencilla: SpeechService.AppendAudioBuffer(in) y un evento OnSendMediaBufferEventArgs(out). Sustituye su cuerpo de Azure Speech por un puente WebSocket de agentes de ElevenLabs que mantenga la misma interfaz:

SpeechService.cs — puente de ElevenLabs (núcleo)
public class SpeechService
{
private readonly AppSettings _settings;
private readonly ILogger _logger;
private ClientWebSocket _ws;
private bool _started;
private bool _connecting;
public event EventHandler<MediaStreamEventArgs> SendMediaBuffer; // agent audio -> call
public event EventHandler FlushMedia; // barge-in: drop queued audio
public SpeechService(AppSettings settings, ILogger logger) { _settings = settings; _logger = logger; }
// Caller audio -> ElevenLabs
public async Task AppendAudioBuffer(AudioMediaBuffer buffer)
{
if (!_started)
{
if (_connecting) return; // a connect attempt is already in flight
_connecting = true;
try { await Connect(); _started = true; }
catch (Exception ex) { _logger.Error(ex, "ElevenLabs connect failed; retry on next frame"); return; }
finally { _connecting = false; }
}
if (_ws?.State != WebSocketState.Open || buffer.Length <= 0) return;
var pcm = new byte[buffer.Length];
Marshal.Copy(buffer.Data, pcm, 0, (int)buffer.Length);
var msg = JsonSerializer.Serialize(new { user_audio_chunk = Convert.ToBase64String(pcm) });
await _ws.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, default);
}
private async Task Connect()
{
_ws = new ClientWebSocket();
// ElevenLabsOrigin: wss://api.elevenlabs.io, or a residency host (.eu./.in./.sg.)
var url = $"{_settings.ElevenLabsOrigin}/v1/convai/conversation?agent_id={_settings.ElevenLabsAgentId}";
await _ws.ConnectAsync(new Uri(url), default);
await _ws.SendAsync(Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(new { type = "conversation_initiation_client_data" })),
WebSocketMessageType.Text, true, default);
_ = Task.Run(ReceiveLoop);
}
private async Task ReceiveLoop()
{
var buf = new byte[32768]; var sb = new StringBuilder();
while (_ws.State == WebSocketState.Open)
{
sb.Clear(); WebSocketReceiveResult r;
do { r = await _ws.ReceiveAsync(buf, default); sb.Append(Encoding.UTF8.GetString(buf, 0, r.Count)); }
while (!r.EndOfMessage);
using var doc = JsonDocument.Parse(sb.ToString());
var type = doc.RootElement.GetProperty("type").GetString();
if (type == "audio") // ElevenLabs audio -> call
Emit(Convert.FromBase64String(doc.RootElement
.GetProperty("audio_event").GetProperty("audio_base_64").GetString()));
else if (type == "ping")
await _ws.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new {
type = "pong", event_id = doc.RootElement.GetProperty("ping_event").GetProperty("event_id").GetInt32() })),
WebSocketMessageType.Text, true, default);
else if (type == "interruption") // barge-in: drop any agent audio still queued
FlushMedia?.Invoke(this, EventArgs.Empty);
}
}
// slice PCM into 20 ms / 640-byte frames the media platform expects
private void Emit(byte[] pcm)
{
var all = new List<AudioMediaBuffer>(); long tick = DateTime.Now.Ticks;
for (int off = 0; off < pcm.Length; off += 640)
{
var frame = new byte[640];
Array.Copy(pcm, off, frame, 0, Math.Min(640, pcm.Length - off));
all.AddRange(Utilities.CreateAudioMediaBuffers(frame, tick, _logger));
tick += 20 * 10000;
}
if (all.Count > 0) SendMediaBuffer?.Invoke(this, new MediaStreamEventArgs { AudioMediaBuffers = all });
}
}

Ambos lados utilizan PCM mono de 16 kHz, por lo que es una transferencia directa en base64: configura el agente en pcm_16000. Cuando se produce una interruption de ElevenLabs (interrupción), el puente genera FlushMedia; conéctalo a tu flujo multimedia para que descarte los AudioMediaBuffer en cola; de lo contrario, el agente seguirá hablando sobre la persona que llama. Consulta la referencia completa de mensajes en la documentación de WebSocket. La finalización de la llamada y la transferencia asistida se explican en las secciones siguientes.

La URL de Connect() accede a un agente público. Para un agente privado, solicita en el servidor una URL firmada de corta duración: GET /v1/convai/conversation/get-signed-url?agent_id=... con tu clave de API, y conéctate a la URL devuelta. En la residencia de datos, configura ElevenLabsOrigin con tu host de residencia (wss://api.eu.residency.elevenlabs.io, .in. o .sg.): las solicitudes de URL firmada usan el host https:// correspondiente.

Paso 4 — Haz que se pueda llamar desde Teams

  1. Activa Calling en el canal de Teams del Azure Bot y configura el webhook de llamadas como https://YOUR_FQDN/api/calling:

    az bot msteams create -g $RG -n el-teams-agent-bot \
    --enable-calling --calling-web-hook "https://YOUR_FQDN/api/calling"

    En el portal, se encuentra en tu recurso de Azure Bot → Channels → Microsoft Teams → pestaña Calling:

    La sección Channels de Azure Bot muestra el canal de Microsoft Teams en buen estado

    Azure Bot → Channels — el canal de Microsoft Teams conectado

    La pestaña Calling del canal de Teams, con Enable calling activado y el webhook de llamadas configurado

    Canal de Microsoft Teams → Calling — llamadas activadas con el webhook del bot
  2. Crea un manifiesto de app de Teams con bots[0].supportsCalling: true y el ID de app del bot; después, cárgalo de forma local (Apps → Manage your apps → Upload a custom app) o publícalo para toda la organización sin usar la interfaz: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (módulo PowerShell de MicrosoftTeams).

Busca la app por nombre en Teams y llámala: el bot responde y el agente de ElevenLabs habla.

Una llamada activa de Teams con el bot agente de ElevenLabs

Una llamada 1:1 en directo con el agente; observa Transfer y Consult en la barra de herramientas de llamada

No necesitas un número de teléfono ni una cuenta de recurso para las llamadas 1:1 por nombre: solo son necesarios para la marcación PSTN. Calls.AccessMedia.All es lo que activa el puente de audio sin procesar.

Chat de texto (el mismo bot)

El mismo Azure Bot también puede responder texto en Teams, así que los usuarios pueden llamar al agente o chatear con él. Las llamadas y la mensajería son canales independientes en el bot: el webhook de llamadas gestiona la voz y un punto de conexión de mensajería de Bot Framework (/api/messages) gestiona el chat.

Un chat de Teams con el bot agente de ElevenLabs respondiendo mensajes de texto

Chateando con el mismo bot en Teams

Dirige el punto de conexión de mensajería del bot al host que lo sirva (el bot multimedia o cualquier otro servicio; no tiene que ser la VM de Windows):

az bot update -g $RG -n el-teams-agent-bot --endpoint "https://YOUR_FQDN/api/messages"

Implementa el punto de conexión con el SDK de Bot Framework y retransmite cada mensaje al agente en modo texto a través del mismo WebSocket de conversación utilizado para la voz: envía un evento user_message y lee el evento agent_response. Primero, activa el campo first message en los ajustes de overrides del agente: el código siguiente lo sobrescribe con un valor vacío para que la respuesta sea la contestación al mensaje del usuario en lugar del saludo del agente:

ChatBot.cs — chat de texto de Teams -> ElevenLabs (modo texto)
public class ChatBot : ActivityHandler
{
private readonly AppSettings _settings;
public ChatBot(AppSettings settings) => _settings = settings;
protected override async Task OnMessageActivityAsync(
ITurnContext<IMessageActivity> turn, CancellationToken ct)
{
var reply = await AskAgent(turn.Activity.Text, ct);
await turn.SendActivityAsync(MessageFactory.Text(reply), ct);
}
private async Task<string> AskAgent(string text, CancellationToken ct)
{
using var ws = new ClientWebSocket();
var url = $"{_settings.ElevenLabsOrigin}/v1/convai/conversation?agent_id={_settings.ElevenLabsAgentId}";
await ws.ConnectAsync(new Uri(url), ct);
// Suppress the agent's greeting: with no override, the first agent_response is the
// configured first message, not the answer to this user_message.
await Send(ws, new
{
type = "conversation_initiation_client_data",
conversation_config_override = new { agent = new { first_message = "" } },
}, ct);
await Send(ws, new { type = "user_message", text }, ct);
var buf = new byte[16384]; var sb = new StringBuilder();
while (ws.State == WebSocketState.Open)
{
sb.Clear(); WebSocketReceiveResult r;
do { r = await ws.ReceiveAsync(buf, ct); sb.Append(Encoding.UTF8.GetString(buf, 0, r.Count)); }
while (!r.EndOfMessage);
using var doc = JsonDocument.Parse(sb.ToString());
switch (doc.RootElement.GetProperty("type").GetString())
{
case "agent_response":
return doc.RootElement.GetProperty("agent_response_event")
.GetProperty("agent_response").GetString();
case "ping":
await Send(ws, new { type = "pong", event_id = doc.RootElement
.GetProperty("ping_event").GetProperty("event_id").GetInt32() }, ct);
break;
}
}
return "Sorry, I couldn't reach the agent.";
}
private static Task Send(ClientWebSocket ws, object msg, CancellationToken ct) =>
ws.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg)),
WebSocketMessageType.Text, true, ct);
}

Regístralo de la forma habitual (un CloudAdapter, el bot mediante AddTransient<IBot, ChatBot>() y un controlador /api/messages) y añade ámbitos de chat a la entrada del bot en el manifiesto:

"bots": [
{ "botId": "YOUR_APP_ID", "supportsCalling": true, "scopes": ["personal", "team", "groupChat"] }
]

El fragmento abre una conversación nueva por mensaje, por lo que cada turno es independiente. Para tener memoria en el chat, mantén un WebSocket abierto por cada conversation.id de Teams (reutilízalo entre turnos) y elimina las sesiones inactivas: así, el agente recordará los mensajes anteriores de ese chat. La anulación de first_message debe estar activada en los ajustes de overrides del agente: el servidor cierra la conversación si se envía una anulación no permitida. Si no puedes activarla, omite la anulación y descarta el primer agent_response de cada sesión (el saludo); después, devuelve el siguiente.

Si las respuestas de chat no llegan, activa el evento de cliente agent_response en los ajustes Advanced del agente: las respuestas de texto se envían mediante ese evento.

Fin de la llamada

Cuando ElevenLabs termina la conversación (su herramienta End Call cierra el WebSocket), cuelga el tramo de Teams:

await this.Call.DeleteAsync(); // after a short delay so the goodbye audio finishes

Transferencia asistida a una persona

El agente activa una herramienta de cliente personalizada transfer_to_human; el bot invita a un usuario de Teams a la llamada en curso (incorporación consultiva) y luego se retira:

var target = new IdentitySet { User = new Identity { Id = humanObjectId } };
await this.Call.Participants.InviteAsync(target, replacesCallId: null);
// suppress the end-call hangup while transferring, and mute the bot

La transferencia consultiva (replacesCallId) requiere que ambas partes sean usuarios de Teams del mismo tenant; los destinos de transferencia PSTN requieren una instancia de aplicación. Para informar primero a la persona, pasa un parámetro reason desde el agente y reprodúceselo antes de conectarla.

Resolución de problemas

La VM solo tiene un núcleo físico. Redimensiónala a ≥ 2 núcleos físicos (por ejemplo, D4s_v3) y reiníciala.

Instala VC++ Redistributable (vcredist140) y la característica de Windows Server-Media-Foundation; después, reinicia el bot.

El error de puerto nulo de EchoBot en 443: corrige HttpHelpers.SetAbsoluteUri (consulta el paso 3). Confirma también que el certificado esté firmado por una CA y sea accesible por el puerto 443.

Confirma que Calling esté activado en el canal de Teams con el webhook /api/calling correcto, que se haya concedido el consentimiento para el permiso de Graph Calls.AccessMedia.All y que los puertos 443/8445/9441 estén abiertos tanto en el NSG como en el firewall de Windows. Si las llamadas antes funcionaban y han dejado de hacerlo, comprueba que el proceso del bot siga ejecutándose en la VM: el límite de ejecución predeterminado de 72 horas del Programador de tareas lo finaliza unos días después del arranque (consulta la advertencia del paso 3).

Enlaces útiles