Graph-Anrufbot

Rufen Sie Ihren ElevenLabs-Agenten in Microsoft Teams per Namen an oder chatten Sie mit ihm – wie mit einem Kollegen.

Überblick

Dieser Ansatz macht den Agenten zu einer anrufbaren Teams-Identität. Ein Nutzer sucht ihn per Namen und ruft ihn im 1:1-Gespräch an. Der Agent antwortet in Echtzeit – ohne Telefonnummer, PSTN oder Communications Credits. Dies ist der einzige Ansatz, bei dem ein Anruf per Namen möglich ist, und der aufwendigste im Betrieb.

Er nutzt einen Microsoft Graph-Echtzeit-Medienbot (die Cloud Communications Calling-Plattform). Das Media SDK (Microsoft.Skype.Bots.Media) unterstützt nur .NET auf Windows Server – für Roh-Audio in Teams-Anrufen gibt es keinen Linux- oder Nicht-.NET-Weg.

Dies ist der einzige Ansatz, der in Teams per Namen anrufbar ist. Für eine einfachere Einrichtung verwenden Sie den Widget- Tab oder ACS, wenn Sie ausdrücklich eine Telefonnummer benötigen.

Funktionsweise

Ein Teams-Nutzer ruft den Bot per Namen an; Teams leitet den Anruf an den Medienbot auf einer Windows-VM weiter, der Roh-Audio im PCM-16k-Format über einen WebSocket mit dem ElevenLabs-Agenten verbindet
Anruf per Name → Medienbot → ElevenLabs

Der Bot antwortet mit anwendungsgehosteten Medien, empfängt 50 Audio-Frames pro Sekunde (20 ms PCM mit 16 kHz), verbindet sie über einen WebSocket mit dem ElevenLabs-Agenten und streamt das Audio des Agenten zurück in den Anruf.

Anforderungen

  1. Eine Azure Bot-Registrierung + App (Entra-App-Registrierung).
  2. Graph-Anwendungsberechtigungen mit Admin-Einwilligung: Calls.AccessMedia.All (Rohmedien) plus Calls.Initiate.All.
  3. Eine Windows Server-VM (≥ 2 physische Kerne – z. B. Standard_D4s_v3) mit öffentlicher IP und offenen Medienports.
  4. Ein von einer CA signiertes TLS-Zertifikat auf einer öffentlichen FQDN für den Medien-/Signalisierungsendpunkt (die Medienplattform lehnt selbstsignierte Zertifikate ab).
  5. Ein ElevenLabs-Agent, der auf beiden Seiten auf PCM 16000 Hz eingestellt ist: TTS-Ausgabeformat im Tab Voice, Audioformat für Nutzereingaben im Tab Advanced.

Eine D2s_v3 (2 vCPU = 1 physischer Kern) schlägt mit MediaPlatform needs a system with at least 2 cores fehl. Verwenden Sie eine Größe mit ≥ 2 physischen Kernen (z. B. D4s_v3).

Berechtigungen und Rollen

BereichRolle / BerechtigungWarum
EntraAnwendungsadministratorApp-Registrierung + Azure Bot erstellen
EntraGlobaler Administrator / Administrator für privilegierte RollenAdmin-Einwilligung für Graph-Anrufberechtigungen erteilen – App-Berechtigungen können nicht selbst genehmigt werden
Microsoft Graph (Anwendung)Calls.AccessMedia.All, Calls.Initiate.All1:1-Anrufe annehmen und auf Rohmedien zugreifen
Azure RBACMitwirkender für die RessourcengruppeWindows-VM + Azure Bot erstellen
Teams-AdministratorUpload benutzerdefinierter Apps erlauben; den Calling-Kanal des Bots aktivierenApp querladen und Anrufe empfangen

Schritt 1 – Bot + Graph-Berechtigungen registrieren

Erstellen Sie eine App-Registrierung und einen daran gebundenen Azure Bot. Erteilen und genehmigen Sie anschließend die Anrufberechtigungen (für die Einwilligung benötigen Sie Global Admin / Privileged Role Admin):

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

Erteilen Sie die beiden Graph-Anwendungsrollen und die Admin-Einwilligung (erfordert Global Admin / Privileged Role Admin). Prüfen Sie anschließend, ob die Zuweisungen erfolgt sind:

# 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

Wenn admin-consent den Fehler Consent validation failed zurückgibt, erteilen Sie die App-Rollen stattdessen direkt für den Dienstprinzipal:

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

Prüfen Sie im Portal im Entra Admin Center unter App registrations → Ihre App → API permissions: Beide Berechtigungen sollten mit grünen Häkchen als Granted angezeigt werden.

Die API-Berechtigungsansicht der App-Registrierung mit Calls.AccessMedia.All und Calls.Initiate.All
als erteilt

App-Registrierung → API-Berechtigungen nach der Admin-Einwilligung

Schritt 2 – Windows-VM, Zertifikat und Ports bereitstellen

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

Auf der VM (der native Code der Medienplattform benötigt diese Komponenten – Windows Server enthält sie standardmäßig nicht):

# 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

Öffnen Sie dieselben Ports in der Windows-Firewall und notieren Sie sich den Fingerabdruck des Zertifikats. Der Bot bindet Kestrel (443 + einen Benachrichtigungsport) und die Medienplattform (8445) daran.

Die eigene *.cloudapp.azure.com-FQDN der VM funktioniert für ein Let’s-Encrypt-Zertifikat – eine separate Domain ist nicht erforderlich.

Schritt 3 – Bot erstellen und ausführen

Starten Sie mit Microsofts microsoft-graph-comms-samples PublicSamples/EchoBot. Es zielt auf net6.0 und wird mit dem .NET SDK erstellt (keine Visual Studio Build Tools erforderlich):

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

Konfigurieren Sie den Abschnitt AppSettings in appsettings.json mit Ihrer AadAppId, Ihrem AadAppSecret, ServiceDnsName/MediaDnsName (der FQDN der VM), CertificateThumbprint und den Ports (Anrufe 443, Benachrichtigungen 9441, Medien 8445). Fügen Sie für die folgende ElevenLabs-Bridge zwei Einstellungen hinzu: ElevenLabsAgentId und ElevenLabsOrigin (wss://api.elevenlabs.io oder Ihren Residency-Host). Führen Sie sie als geplante Windows-Aufgabe bzw. Dienst aus, damit sie Neustarts übersteht.

Das standardmäßige Ausführungszeitlimit (72 Stunden) der Aufgabenplanung beendet langlaufende Aufgaben unbemerkt. Ein beim Start gestarteter Bot fällt drei Tage später aus und Anrufe schlagen mit „we couldn’t connect you“ fehl. Deaktivieren Sie das Limit und fügen Sie einen Neustart bei Fehlern hinzu:

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

Der Standard-EchoBot stürzt bei einem Anruf über den Standardport 443 ab: HttpHelpers.SetAbsoluteUri ruft req.Host.Port.Value auf, was null ist, wenn der Host-Header keinen expliziten Port enthält. Ändern Sie dies in req.Host.Port ?? (req.IsHttps ? 443 : 80).

Echo durch ElevenLabs ersetzen

Die Audioschnittstelle von EchoBot ist klar: SpeechService.AppendAudioBuffer(in) und ein OnSendMediaBufferEventArgs(out)-Ereignis. Ersetzen Sie den Azure-Speech-Body durch eine ElevenLabs-Agent-WebSocket-Bridge mit derselben Oberfläche:

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 Seiten verwenden PCM mit 16 kHz und Mono. Daher ist es ein Base64-Passthrough – stellen Sie den Agenten auf pcm_16000 ein. Bei einer ElevenLabs-interruption (Unterbrechung) löst die Bridge FlushMedia aus. Verbinden Sie dies mit Ihrem Medienstream, damit ausstehende AudioMediaBuffer verworfen werden. Andernfalls spricht der Agent weiter über den Anrufer hinweg. Die vollständige Nachrichtenreferenz finden Sie in der WebSocket-Dokumentation. Das Auflegen am Anrufende und die Warmübergabe werden in den folgenden Abschnitten behandelt.

Die URL in Connect() erreicht einen öffentlichen Agenten. Für einen privaten Agenten fordern Sie serverseitig eine kurzlebige signierte URL an – GET /v1/convai/conversation/get-signed-url?agent_id=... mit Ihrem API-Key – und verbinden Sie sich stattdessen mit der zurückgegebenen URL. Setzen Sie bei Datenresidenz ElevenLabsOrigin auf Ihren Residency-Host (wss://api.eu.residency.elevenlabs.io, .in. oder .sg.) – Anfragen nach signierten URLs verwenden den passenden https://-Host.

Schritt 4 – In Teams anrufbar machen

  1. Aktivieren Sie Calling im Teams-Kanal des Azure Bots und setzen Sie den Calling-Webhook auf 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"

    Im Portal finden Sie dies unter Ihrer Azure-Bot-Ressource → Channels → Microsoft Teams → Tab Calling:

    Die Azure-Bot-Channel-Ansicht mit dem Microsoft-Teams-Kanal als
fehlerfrei

    Azure Bot → Channels – der verbundene Microsoft-Teams-Kanal

    Der Calling-Tab des Teams-Kanals mit aktivierter Option Enable calling und gesetztem Calling-Webhook

    Microsoft-Teams-Kanal → Calling – Calling mit dem Webhook des Bots aktiviert
  2. Erstellen Sie ein Teams-App-Manifest mit bots[0].supportsCalling: true und der App-ID des Bots und laden Sie es quer (Apps → Manage your apps → Upload a custom app), oder veröffentlichen Sie es ohne Benutzeroberfläche organisationsweit: New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip (MicrosoftTeams-PowerShell-Modul).

Suchen Sie die App in Teams per Namen und rufen Sie sie an – der Bot antwortet und der ElevenLabs-Agent spricht.

Ein aktiver Teams-Anruf mit dem ElevenLabs-Agenten-
Bot

Ein aktiver 1:1-Anruf mit dem Agenten – beachten Sie Transfer und Consult in der Anrufsymbolleiste

Für 1:1-Anrufe per Name ist keine Telefonnummer oder Ressourcenidentität erforderlich – diese werden nur für PSTN- Einwahl benötigt. Calls.AccessMedia.All aktiviert die Roh-Audio-Bridge.

Textchat (derselbe Bot)

Derselbe Azure Bot kann in Teams auch auf Text antworten — Nutzer können den Agenten also anrufen oder mit ihm chatten. Anrufe und Nachrichten sind unabhängige Kanäle des Bots: Der Calling-Webhook verarbeitet Sprache, und ein Bot-Framework-Messaging-Endpunkt (/api/messages) verarbeitet Chats.

Ein Teams-Chat, in dem der ElevenLabs-Agenten-Bot auf Textnachrichten antwortet

Chat mit demselben Bot in Teams

Richten Sie den Messaging-Endpunkt des Bots auf den Host, der ihn bereitstellt (den Medienbot oder einen anderen Dienst — es muss nicht die Windows-VM sein):

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

Implementieren Sie den Endpunkt mit dem Bot Framework SDK und leiten Sie jede Nachricht im Textmodus über denselben für Sprache verwendeten Conversation-WebSocket an den Agenten weiter — senden Sie ein user_message-Event und lesen Sie das agent_response-Event. Aktivieren Sie zuerst das Feld erste Nachricht in den Override-Einstellungen des Agenten — der folgende Code überschreibt es mit einem leeren Wert, sodass die Antwort auf die Nachricht des Nutzers und nicht die Begrüßung des Agenten zurückgegeben wird:

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

Registrieren Sie ihn auf die übliche Weise (einen CloudAdapter, den Bot über AddTransient<IBot, ChatBot>() und einen /api/messages-Controller) und fügen Sie dem Bot-Eintrag des Manifests Chat-Bereiche hinzu:

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

Das Snippet öffnet für jede Nachricht eine neue Conversation, daher ist jeder Turn unabhängig. Für Chat-Speicher halten Sie einen WebSocket pro Teams-conversation.id geöffnet (verwenden Sie ihn über mehrere Turns hinweg) und bereinigen Sie inaktive Sitzungen — der Agent erinnert sich dann an frühere Nachrichten in diesem Chat. Der Override für first_message muss in den Override-Einstellungen des Agenten aktiviert sein — der Server schließt die Conversation, wenn ein nicht erlaubter Override gesendet wird. Falls Sie ihn nicht aktivieren können, lassen Sie den Override weg und verwerfen stattdessen die erste agent_response jeder Sitzung (die Begrüßung) und geben Sie die nächste zurück.

Falls Chat-Antworten nie eintreffen, aktivieren Sie das agent_response-Client- Event in den erweiterten Einstellungen des Agenten — Textantworten werden über dieses Event bereitgestellt.

Anruf beenden

Wenn ElevenLabs die Conversation beendet (das Tool Anruf beenden schließt den WebSocket), legen Sie die Teams-Verbindung auf:

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

Warme Übergabe an einen Mitarbeiter

Der Agent löst ein benutzerdefiniertes transfer_to_human-Client-Tool aus; der Bot lädt einen Teams-Nutzer in den laufenden Anruf ein (beratendes Hinzufügen) und tritt dann zurück:

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

Die beratende Übergabe (replacesCallId) setzt voraus, dass beide Parteien Teams-Nutzer im selben Tenant sind; PSTN-Übergabeziele erfordern eine Anwendungsinstanz. Um den Mitarbeiter zuerst zu informieren, übergeben Sie einen reason-Parameter vom Agenten und spielen Sie ihn dem Mitarbeiter vor dem Verbinden vor.

Fehlerbehebung

Die VM hat nur einen physischen Kern. Ändern Sie die Größe auf ≥ 2 physische Kerne (z. B. D4s_v3) und starten Sie neu.

Installieren Sie das VC++ Redistributable (vcredist140) und das Windows-Feature Server-Media-Foundation, und starten Sie dann den Bot neu.

Der EchoBot-Port-Null-Bug auf 443 — patchen Sie HttpHelpers.SetAbsoluteUri (siehe Schritt 3). Prüfen Sie außerdem, ob das Zertifikat von einer CA signiert und über 443 erreichbar ist.

Prüfen Sie, ob Calling im Teams-Kanal mit dem korrekten /api/calling-Webhook aktiviert ist, die Graph-Berechtigung Calls.AccessMedia.All genehmigt wurde und die Ports 443/8445/9441 sowohl in der NSG als auch in der Windows-Firewall geöffnet sind. Falls Anrufe früher funktionierten und dann nicht mehr, prüfen Sie, ob der Bot-Prozess noch auf der VM läuft — das standardmäßige Ausführungslimit der Aufgabenplanung von 72 Stunden beendet ihn einige Tage nach dem Start (siehe Warnung in Schritt 3).