Bot de chamadas do Graph

Ligue ou converse com seu agente da ElevenLabs pelo nome no Microsoft Teams, como se fosse um colega.

Visão geral

Essa abordagem transforma o agente em uma identidade do Teams que pode receber chamadas. Um usuário o busca pelo nome e faz uma chamada 1:1, e o agente responde em tempo real — sem número de telefone, PSTN ou Créditos de Comunicação. É a única abordagem que permite chamadas pelo nome e também a mais complexa de executar.

Ela usa um bot de mídia em tempo real do Microsoft Graph (a plataforma de chamadas do Cloud Communications). O SDK de mídia (Microsoft.Skype.Bots.Media) funciona apenas com .NET no Windows Server — não há suporte para Linux ou alternativas fora do .NET para áudio bruto em chamadas do Teams.

Esta é a única abordagem que permite chamadas pelo nome no Teams. Para uma configuração mais simples, prefira a aba do widget ou use o ACS se você quiser especificamente um número de telefone.

Como funciona

Um usuário do Teams chama o bot pelo nome; o Teams encaminha a chamada para o bot de mídia em uma VM Windows, que conecta o áudio PCM 16k bruto ao agente da ElevenLabs por WebSocket
Chamada pelo nome → bot de mídia → ElevenLabs

O bot atende com mídia hospedada pelo aplicativo, recebe 50 frames de áudio por segundo (PCM de 16 kHz e 20 ms), conecta-os ao agente da ElevenLabs por WebSocket e transmite o áudio do agente de volta para a chamada.

Requisitos

  1. Um registro de Azure Bot + aplicativo (registro de aplicativo do Entra).
  2. Permissões de aplicativo do Graph com consentimento de administrador: Calls.AccessMedia.All (mídia bruta) e Calls.Initiate.All.
  3. Uma VM Windows Server (≥ 2 núcleos físicos — por exemplo, Standard_D4s_v3) com IP público e portas de mídia abertas.
  4. Um certificado TLS assinado por uma CA em um FQDN público para o endpoint de mídia/sinalização (a plataforma de mídia rejeita certificados autoassinados).
  5. Um agente da ElevenLabs configurado como PCM 16000 Hz nos dois lados: formato de saída de TTS na aba Voice e formato de áudio de entrada do usuário na aba Advanced.

Uma D2s_v3 (2 vCPU = 1 núcleo físico) falha com MediaPlatform needs a system with at least 2 cores. Use um tamanho com ≥ 2 núcleos físicos (por exemplo, D4s_v3).

Permissões e funções

EscopoFunção / permissãoMotivo
EntraAdministrador de Aplicativoscriar o registro do aplicativo + Azure Bot
EntraAdministrador Global / Administrador de Funções Privilegiadasconceder consentimento de administrador para as permissões de chamadas do Graph — permissões de aplicativo não permitem autoconcessão
Microsoft Graph (aplicativo)Calls.AccessMedia.All, Calls.Initiate.Allatender chamadas 1:1 e acessar mídia bruta
Azure RBACColaborador no grupo de recursoscriar a VM Windows + Azure Bot
Administrador do Teamspermitir o upload de aplicativo personalizado; habilitar o canal Calling do botcarregar o aplicativo localmente e receber chamadas

Etapa 1 — Registrar o bot + permissões do Graph

Crie um registro de aplicativo e um Azure Bot vinculado a ele. Em seguida, conceda as permissões de chamada e dê consentimento a elas (você precisa ser Administrador Global / Administrador de Funções Privilegiadas para consentir):

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

Conceda as duas funções de aplicativo do Graph e o consentimento de administrador (requer Administrador Global / Administrador de Funções Privilegiadas) e confirme que as atribuições foram aplicadas:

# 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

Se admin-consent retornar Consent validation failed, conceda as funções do aplicativo diretamente na entidade de serviço:

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

No portal, verifique na central de administração do Entra, em App registrations → seu aplicativo → API permissions: ambas as permissões devem aparecer como Granted, com marcas de verificação verdes.

A tela de permissões de API do registro do aplicativo mostrando Calls.AccessMedia.All e Calls.Initiate.All
concedidas

Registro do aplicativo → permissões de API após o consentimento de administrador

Etapa 2 — Provisionar a VM Windows, o certificado e as portas

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

Na VM (o código nativo da plataforma de mídia precisa deles — o Windows Server não os inclui por padrão):

# 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

Abra as mesmas portas no firewall do Windows e anote a impressão digital do certificado — o bot vincula o Kestrel (443 + uma porta de notificações) e a plataforma de mídia (8445) a ele.

O próprio FQDN *.cloudapp.azure.com da VM funciona com um certificado Let’s Encrypt — não é necessário ter um domínio separado.

Etapa 3 — Compilar e executar o bot

Comece pelo PublicSamples/EchoBot do microsoft-graph-comms-samples da Microsoft — ele usa net6.0 e é compilado com o SDK do .NET (não precisa das 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

Configure a seção AppSettings do appsettings.json com seu AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (o FQDN da VM), CertificateThumbprint e as portas (chamadas 443, notificações 9441, mídia 8445). Adicione duas configurações para a ponte da ElevenLabs abaixo: ElevenLabsAgentId e ElevenLabsOrigin (wss://api.elevenlabs.io ou seu host de residência). Execute-o como uma tarefa agendada / serviço do Windows para que sobreviva a reinicializações.

O limite de tempo de execução padrão do Agendador de Tarefas (72 horas) encerra silenciosamente tarefas de longa duração — um bot iniciado na inicialização para de funcionar três dias depois e as chamadas falham com “we couldn’t connect you”. Desative o limite e adicione reinicialização em caso de falha:

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

O EchoBot padrão falha em uma chamada feita para a porta padrão 443: HttpHelpers.SetAbsoluteUri chama req.Host.Port.Value, que é nulo quando o cabeçalho Host não tem uma porta explícita. Corrija-o para req.Host.Port ?? (req.IsHttps ? 443 : 80).

Substitua o eco pela ElevenLabs

A integração de áudio do EchoBot é simples: SpeechService.AppendAudioBuffer(in) e um evento OnSendMediaBufferEventArgs(out). Substitua o corpo do Azure Speech por uma ponte WebSocket do agente da ElevenLabs que mantenha a mesma interface:

SpeechService.cs — ponte da ElevenLabs (principal)
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 });
}
}

Os dois lados usam PCM mono de 16 kHz, portanto é uma passagem direta em base64 — configure o agente como pcm_16000. Em uma interruption (interrupção de fala) da ElevenLabs, a ponte gera FlushMedia; conecte isso ao seu fluxo de mídia para descartar quaisquer AudioMediaBuffers em fila. Caso contrário, o agente continuará falando sobre o interlocutor. A referência completa de mensagens está na documentação de WebSocket. O desligamento no fim da chamada e a transferência assistida são abordados nas seções abaixo.

A URL em Connect() acessa um agente público. Para um agente privado, solicite uma URL assinada de curta duração no servidor — GET /v1/convai/conversation/get-signed-url?agent_id=... com sua chave de API — e conecte-se à URL retornada. Em residência de dados, defina ElevenLabsOrigin como seu host de residência (wss://api.eu.residency.elevenlabs.io, .in. ou .sg.) — as solicitações de URL assinada usam o host https:// correspondente.

Etapa 4 — Permitir chamadas no Teams

  1. Habilite Calling no canal Teams do Azure Bot e defina o webhook de chamadas 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"

    No portal, isso fica em seu recurso do Azure Bot → Channels → Microsoft Teams → aba Calling:

    A tela Channels do Azure Bot listando o canal Microsoft Teams como
íntegro

    Azure Bot → Channels — o canal Microsoft Teams conectado

    A aba Calling do canal Teams com Enable calling marcado e o webhook de chamadas
configurado

    Canal Microsoft Teams → Calling — chamadas habilitadas com o webhook do bot
  2. Crie um manifesto de aplicativo do Teams com bots[0].supportsCalling: true e o ID do aplicativo do bot. Em seguida, carregue-o localmente (Apps → Manage your apps → Upload a custom app) ou publique-o para toda a organização sem usar a interface: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (módulo PowerShell MicrosoftTeams).

Busque o aplicativo pelo nome no Teams e ligue para ele — o bot atende e o agente da ElevenLabs fala.

Uma chamada ativa do Teams com o bot do agente da ElevenLabs

Uma chamada 1:1 ao vivo com o agente — observe Transfer e Consult na barra de ferramentas da chamada

Não é necessário ter número de telefone ou conta de recurso para chamadas 1:1 pelo nome — eles são necessários apenas para chamadas PSTN. Calls.AccessMedia.All é o que habilita a ponte de áudio bruto.

Chat de texto (mesmo bot)

O mesmo bot do Azure também pode responder por texto no Teams — assim, os usuários podem ligar para o agente ou conversar com ele por chat. Chamadas e mensagens são canais independentes no bot: o webhook de chamadas processa a voz, e um endpoint de mensagens (/api/messages) do Bot Framework processa o chat.

Um chat no Teams com o bot agente da ElevenLabs respondendo a mensagens de texto

Conversando por chat com o mesmo bot no Teams

Aponte o endpoint de mensagens do bot para o host que o atende (o bot de mídia ou qualquer outro serviço — não precisa ser a VM do Windows):

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

Implemente o endpoint com o SDK do Bot Framework e encaminhe cada mensagem para o agente no modo de texto pelo mesmo WebSocket de conversa usado para voz — envie um evento user_message e leia o evento agent_response. Primeiro, ative o campo de primeira mensagem nas configurações de substituições do agente — o código abaixo o substitui por um valor vazio para que a resposta seja a resposta à mensagem do usuário, em vez da saudação do agente:

ChatBot.cs — chat de texto do Teams -> ElevenLabs (modo de 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);
}

Registre-o da forma padrão (um CloudAdapter, o bot via AddTransient<IBot, ChatBot>() e um controlador /api/messages) e adicione escopos de chat à entrada do bot no manifesto:

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

O exemplo abre uma nova conversa por mensagem, então cada turno é independente. Para ter memória no chat, mantenha um WebSocket aberto por conversation.id do Teams (reutilize-o entre os turnos) e encerre sessões inativas — assim, o agente se lembra das mensagens anteriores nesse chat. A substituição de first_message precisa estar ativada nas configurações de substituições do agente — o servidor fecha a conversa se uma substituição não permitida for enviada. Se não puder ativá-la, omita a substituição e, em vez disso, descarte o primeiro agent_response de cada sessão (a saudação) e retorne o seguinte.

Se as respostas do chat nunca chegarem, ative o evento do cliente agent_response nas configurações Avançadas do agente — as respostas de texto são enviadas por esse evento.

Fim da chamada

Quando a ElevenLabs encerra a conversa (a ferramenta Encerrar chamada fecha o WebSocket), desligue a conexão do Teams:

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

Transferência assistida para uma pessoa

O agente dispara uma ferramenta de cliente personalizada transfer_to_human; o bot convida um usuário do Teams para a chamada em andamento (adição consultiva) e então sai da chamada:

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

A transferência consultiva (replacesCallId) exige que ambas as partes sejam usuários do Teams no **mesmo locatário **; destinos de transferência PSTN exigem uma instância de aplicativo. Para contextualizar a pessoa primeiro, envie um parâmetro reason do agente e reproduza-o para a pessoa antes de conectar as chamadas.

Solução de problemas

A VM tem apenas um núcleo físico. Redimensione para ≥ 2 núcleos físicos (por exemplo, D4s_v3) e reinicie.

Instale o VC++ Redistributable (vcredist140) e o recurso do Windows Server-Media-Foundation e, em seguida, reinicie o bot.

O bug de porta nula do EchoBot na 443 — corrija HttpHelpers.SetAbsoluteUri (consulte a Etapa 3). Confirme também que o certificado é assinado por uma CA e está acessível na 443.

Confirme que Calling está ativado no canal do Teams com o webhook /api/calling correto, que a permissão do Graph Calls.AccessMedia.All foi concedida e que as portas 443/8445/9441 estão abertas tanto no NSG quanto no firewall do Windows. Se as chamadas funcionavam antes e pararam, verifique se o processo do bot ainda está em execução na VM — o limite padrão de execução de 72 horas do Agendador de Tarefas o encerra alguns dias após a inicialização (consulte o aviso na Etapa 3).