Skip to navigation

Graph calling-bot

Bel of chat met je ElevenLabs-agent op naam in Microsoft Teams, net als met een collega.

Overzicht

Met deze aanpak wordt de agent een belbare Teams-identiteit. Een gebruiker zoekt deze op naam en belt deze 1-op-1, waarna de agent realtime antwoordt — zonder telefoonnummer, PSTN of Communications Credits. Dit is de enige aanpak waarbij je op naam kunt bellen, en ook de meest complexe om te draaien.

Deze gebruikt een Microsoft Graph-realtime-mediabot (het Cloud Communications-belplatform). De media-SDK (Microsoft.Skype.Bots.Media) werkt alleen met .NET op Windows Server — er is geen Linux- of niet-.NET-optie voor ruwe audio in Teams-gesprekken.

Dit is de enige aanpak die je op naam kunt bellen in Teams. Kies de widget- tab voor een eenvoudigere configuratie, of ACS als je specifiek een telefoonnummer wilt.

Hoe het werkt

Een Teams-gebruiker belt de bot op naam; Teams routeert het gesprek naar de mediabot op een Windows-VM, die ruwe PCM 16k-audio via een WebSocket doorstuurt naar de ElevenLabs-agent
Bellen op naam → mediabot → ElevenLabs

De bot beantwoordt gesprekken met media die door de applicatie wordt gehost, ontvangt 50 audioframes/sec (20 ms PCM 16 kHz), stuurt deze via een WebSocket door naar de ElevenLabs-agent en streamt de audio van de agent terug naar het gesprek.

Vereisten

  1. Een Azure Bot-registratie + app (Entra-appregistratie).
  2. Graph-applicatiemachtigingen met toestemming van de beheerder: Calls.AccessMedia.All (ruwe media) plus Calls.Initiate.All.
  3. Een Windows Server-VM (≥ 2 fysieke cores — bijvoorbeeld Standard_D4s_v3) met een openbaar IP-adres en geopende mediapoorten.
  4. Een door een CA ondertekend TLS-certificaat op een openbare FQDN voor het media-/signaling-endpoint (het mediaplatform weigert zelfondertekende certificaten).
  5. Een ElevenLabs-agent die op beide kanten is ingesteld op PCM 16000 Hz: TTS-uitvoerformaat op het tabblad Voice, invoeraudioformaat van de gebruiker op het tabblad Advanced.

Een D2s_v3 (2 vCPU = 1 fysieke core) faalt met MediaPlatform needs a system with at least 2 cores. Gebruik een formaat met ≥ 2 fysieke cores (bijvoorbeeld D4s_v3).

Machtigingen en rollen

BereikRol / machtigingWaarom
EntraApplication Administratorde appregistratie + Azure Bot maken
EntraGlobal Administrator / Privileged Role Administratorbeheerderstoestemming verlenen voor de Graph-belmachtigingen — appmachtigingen kunnen niet zelf van toestemming worden voorzien
Microsoft Graph (applicatie)Calls.AccessMedia.All, Calls.Initiate.All1-op-1-gesprekken beantwoorden en toegang krijgen tot ruwe media
Azure RBACContributor voor de resourcegroepde Windows-VM + Azure Bot maken
Teams-beheerderupload van aangepaste apps toestaan; het Calling-kanaal van de bot inschakelende app sideloaden en gesprekken ontvangen

Stap 1 — Registreer de bot + Graph-machtigingen

Maak een appregistratie en een daaraan gekoppelde Azure Bot, en verleen vervolgens toestemming voor de belmachtigingen (je hebt Global Admin / Privileged Role Admin nodig om toestemming te geven):

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

Verleen de twee Graph-applicatierollen en beheerderstoestemming (vereist Global Admin / Privileged Role Admin), en controleer vervolgens of de toewijzingen zijn doorgevoerd:

# 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

Als admin-consent Consent validation failed retourneert, verleen de approllen dan rechtstreeks aan de service-principal:

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

Controleer in de portal in het Entra-beheercentrum onder App registrations → je app → API permissions: beide machtigingen moeten met groene vinkjes als Granted worden weergegeven.

Het scherm met API-machtigingen van de appregistratie, waarop Calls.AccessMedia.All en Calls.Initiate.All als
verleend worden weergegeven

Appregistratie → API-machtigingen na beheerderstoestemming

Stap 2 — Richt de Windows-VM, het certificaat en de poorten in

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

Op de VM (de native code van het mediaplatform heeft deze nodig — Windows Server bevat ze standaard niet):

# 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

Open dezelfde poorten in de Windows Firewall en noteer de thumbprint van het certificaat — de bot koppelt Kestrel (443 + een meldingspoort) en het mediaplatform (8445) hieraan.

De eigen *.cloudapp.azure.com-FQDN van de VM werkt voor een Let’s Encrypt-certificaat — een apart domein is niet nodig.

Stap 3 — Bouw en voer de bot uit

Begin met Microsoft’s microsoft-graph-comms-samples PublicSamples/EchoBot — deze is gericht op net6.0 en bouwt met de .NET SDK (geen Visual Studio Build Tools nodig):

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

Configureer de sectie AppSettings van appsettings.json met je AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (de FQDN van de VM), CertificateThumbprint en poorten (bellen 443, meldingen 9441, media 8445). Voeg voor de ElevenLabs-bridge hieronder twee instellingen toe: ElevenLabsAgentId en ElevenLabsOrigin (wss://api.elevenlabs.io, of je residency-host). Voer deze uit als een Windows-geplande taak / service, zodat deze na herstarts blijft draaien.

De standaard uitvoertijdlimiet (72 uur) van Task Scheduler beëindigt langlopende taken stilzwijgend — een bot die bij het opstarten wordt gestart, stopt drie dagen later en gesprekken mislukken met “we couldn’t connect you”. Schakel de limiet uit en voeg opnieuw starten bij een fout toe:

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

De standaard EchoBot crasht bij een gesprek naar de standaardpoort 443: HttpHelpers.SetAbsoluteUri roept req.Host.Port.Value aan, wat null is als de Host-header geen expliciete poort heeft. Pas dit aan naar req.Host.Port ?? (req.IsHttps ? 443 : 80).

Vervang de echo door ElevenLabs

De audionaad van EchoBot is helder: SpeechService.AppendAudioBuffer(in) en een event OnSendMediaBufferEventArgs(out). Vervang de Azure-Speech-body door een ElevenLabs-agent-WebSocket-bridge die hetzelfde oppervlak behoudt:

SpeechService.cs — ElevenLabs-bridge (kern)
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 });
}
}

Beide kanten gebruiken mono PCM 16 kHz, dus het is een base64-doorvoer — stel de agent in op pcm_16000. Bij een ElevenLabs-interruption (barge-in) activeert de bridge FlushMedia; koppel dit aan je mediastream zodat alle wachtrijstaande AudioMediaBuffers worden verwijderd, anders blijft de agent over de beller heen praten. De volledige berichtreferentie staat in de WebSocket-documentatie. Het ophangen aan het einde van een gesprek en warme overdracht worden in de onderstaande secties behandeld.

De URL in Connect() bereikt een openbare agent. Vraag voor een privéagent server-side een kortdurende ondertekende URL aan — GET /v1/convai/conversation/get-signed-url?agent_id=... met je API- sleutel — en maak in plaats daarvan verbinding met de geretourneerde URL. Stel bij data- residency ElevenLabsOrigin in op je residency- host (wss://api.eu.residency.elevenlabs.io, .in., of .sg.) — aanvragen voor ondertekende URL’s gebruiken de bijbehorende https://-host.

Stap 4 — Maak de bot belbaar in Teams

  1. Schakel Calling in op het Teams-kanaal van de Azure Bot en stel de calling webhook in op 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"

    In de portal vind je dit bij je Azure Bot-resource → Channels → Microsoft Teams → tabblad Calling:

    Het scherm Azure Bot Channels waarop het Microsoft Teams-kanaal als
gezond wordt weergegeven

    Azure Bot → Channels — het verbonden Microsoft Teams-kanaal

    Het tabblad Calling van het Teams-kanaal waarop Enable calling is aangevinkt en de calling webhook is
ingesteld

    Microsoft Teams-kanaal → Calling — bellen ingeschakeld met de webhook van de bot
  2. Bouw een Teams-appmanifest met bots[0].supportsCalling: true en de app-ID van de bot, en sideload dit (Apps → Manage your apps → Upload a custom app), of publiceer het organisatiebreed zonder de UI: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (MicrosoftTeams PowerShell-module).

Zoek de app op naam in Teams en bel deze — de bot antwoordt en de ElevenLabs-agent spreekt.

Een actief Teams-gesprek met de ElevenLabs-agent-
bot

Een live 1-op-1-gesprek met de agent — let op Transfer en Consult in de gesprekswerkbalk

Voor 1-op-1-bellen op naam heb je geen telefoonnummer of resource account nodig — die zijn alleen voor PSTN- inbellen. Calls.AccessMedia.All maakt de raw-audiobridge mogelijk.

Tekstchat (dezelfde bot)

Dezelfde Azure Bot kan ook tekst beantwoorden in Teams — gebruikers kunnen de agent dus bellen of ermee chatten. Bellen en berichten versturen zijn onafhankelijke kanalen op de bot: de calling webhook verwerkt spraak en een Bot Framework-messaging endpoint (/api/messages) verwerkt chat.

Een Teams-chat waarin de ElevenLabs-agentbot tekstberichten
beantwoordt

Chatten met dezelfde bot in Teams

Wijs het messaging endpoint van de bot naar de host die dit aanbiedt (de mediabot of een andere service — dit hoeft niet de Windows-VM te zijn):

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

Implementeer het endpoint met de Bot Framework SDK en stuur elk bericht in tekstmodus door naar de agent via dezelfde conversation WebSocket die voor spraak wordt gebruikt — verstuur een event user_message en lees het event agent_response. Schakel eerst het veld first message in onder de instellingen voor overrides van de agent — de onderstaande code overschrijft dit met leeg, zodat het antwoord een reactie is op het bericht van de gebruiker in plaats van de begroeting van de agent:

ChatBot.cs — Teams-tekstchat -> ElevenLabs (tekstmodus)
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);
}

Registreer deze op de standaardmanier (een CloudAdapter, de bot via AddTransient<IBot, ChatBot>() en een controller voor /api/messages) en voeg chatscopes toe aan de botvermelding in het manifest:

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

Het codefragment opent een nieuw gesprek per bericht, dus elke beurt staat op zichzelf. Houd voor chat- geheugen één WebSocket open per Teams-conversation.id (hergebruik deze tussen beurten) en ruim inactieve sessies op — de agent onthoudt dan eerdere berichten in die chat. De override first_message moet zijn ingeschakeld onder de instellingen voor overrides van de agent — de server sluit het gesprek als een niet-toegestane override wordt verstuurd. Als je deze niet kunt inschakelen, laat je de override weg en negeer je in plaats daarvan de eerste agent_response van elke sessie (de begroeting), waarna je de volgende terugstuurt.

Als chatantwoorden nooit aankomen, schakel dan het agent_response-client- event in de Advanced- instellingen van de agent in — tekstreacties worden via dat event geleverd.

Einde van het gesprek

Wanneer ElevenLabs het gesprek beëindigt (de tool End Call sluit de WebSocket), hang je de Teams-kant op:

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

Warme overdracht naar een persoon

De agent activeert een aangepaste clienttool transfer_to_human; de bot nodigt een Teams-gebruiker uit voor het actieve gesprek (consultatieve toevoeging) en trekt zich vervolgens terug:

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

Consultatieve overdracht (replacesCallId) vereist dat beide partijen Teams-gebruikers in dezelfde tenant zijn; PSTN-overdrachtsdoelen vereisen een application instance. Geef de persoon eerst een briefing door een parameter reason van de agent door te geven en deze aan de persoon af te spelen voordat je de verbinding maakt.

Problemen oplossen

De VM heeft slechts één fysieke core. Wijzig de grootte naar ≥ 2 fysieke cores (bijvoorbeeld D4s_v3) en start opnieuw op.

Installeer de VC++ Redistributable (vcredist140) en de Windows- functie Server-Media-Foundation, en start de bot vervolgens opnieuw.

De EchoBot-poort-null-bug op 443 — pas HttpHelpers.SetAbsoluteUri aan (zie stap 3). Controleer ook of het certificaat door een CA is ondertekend en bereikbaar is op 443.

Controleer of Calling is ingeschakeld op het Teams-kanaal met de juiste /api/calling-webhook, of de Graph-machtiging Calls.AccessMedia.All toestemming heeft en of poorten 443/8445/9441 zowel in de NSG als in de Windows Firewall openstaan. Als bellen eerder werkte en is gestopt, controleer dan of het botproces nog steeds op de VM draait — de standaarduitvoertijdlimiet van 72 uur van Task Scheduler beëindigt het enkele dagen na het opstarten (zie de waarschuwing in stap 3).