Graph calling bot
Call or chat with your ElevenLabs agent by name inside Microsoft Teams, like a colleague.
Overview
Section titled “Overview”This approach makes the agent a callable Teams identity. A user searches for it by name and calls it 1:1, and the agent answers in real time — no phone number, PSTN, or Communications Credits. It’s the only approach callable by name, and the most involved to run.
It uses a Microsoft Graph real-time media bot (the Cloud Communications calling platform). The media SDK (Microsoft.Skype.Bots.Media) is .NET on Windows Server only — there is no Linux or non-.NET path for raw audio in Teams calls.
How it works
Section titled “How it works”
Call by name → media bot → ElevenLabs
The bot answers with application-hosted media, receives 50 audio frames/sec (20 ms PCM 16 kHz), bridges them to the ElevenLabs agent over a WebSocket, and streams the agent’s audio back into the call.
Requirements
Section titled “Requirements”- An Azure Bot registration + app (Entra app registration).
- Graph application permissions with admin consent:
Calls.AccessMedia.All(raw media) plusCalls.Initiate.All. - A Windows Server VM (≥ 2 physical cores — e.g.
Standard_D4s_v3) with a public IP and open media ports. - A CA-signed TLS certificate on a public FQDN for the media/signaling endpoint (the media platform rejects self-signed certs).
- An ElevenLabs agent set to PCM 16000 Hz on both legs: TTS output format on the Voice tab, user input audio format on the Advanced tab.
Permissions & roles
Section titled “Permissions & roles”| Scope | Role / permission | Why |
|---|---|---|
| Entra | Application Administrator | create the app registration + Azure Bot |
| Entra | Global Administrator / Privileged Role Administrator | grant admin consent for the Graph calling permissions — app permissions cannot be self-consented |
| Microsoft Graph (application) | Calls.AccessMedia.All, Calls.Initiate.All |
answer 1:1 calls and access raw media |
| Azure RBAC | Contributor on the resource group | create the Windows VM + Azure Bot |
| Teams admin | allow custom app upload; enable the bot Calling channel | sideload the app and receive calls |
Step 1 — Register the bot + Graph permissions
Section titled “Step 1 — Register the bot + Graph permissions”Create an app registration and an Azure Bot bound to it, then grant + consent the calling permissions (you need Global Admin / Privileged Role Admin to consent):
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 S1Grant the two Graph application roles and admin consent (needs Global Admin / Privileged Role Admin), then confirm the assignments landed:
# 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 tsvIn the portal, verify in the Entra admin center under App registrations → your app → API permissions: both permissions should show Granted with green checks.
App registration → API permissions after admin consent
Step 2 — Provision the Windows VM, cert and ports
Section titled “Step 2 — Provision the Windows VM, cert and ports”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 300On the VM (the media platform’s native code needs these — Windows Server lacks them by default):
# 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 --accepttosOpen the same ports in the Windows firewall, and note the cert thumbprint — the bot binds Kestrel (443 + a notifications port) and the media platform (8445) to it.
Step 3 — Build and run the bot
Section titled “Step 3 — Build and run the bot”Start from Microsoft’s microsoft-graph-comms-samples PublicSamples/EchoBot — it targets net6.0 and builds with the .NET SDK (no Visual Studio Build Tools needed):
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 ReleaseConfigure the AppSettings section of appsettings.json with your AadAppId, AadAppSecret, ServiceDnsName/MediaDnsName (the VM FQDN), CertificateThumbprint, and ports (calling 443, notifications 9441, media 8445). Add two settings for the ElevenLabs bridge below: ElevenLabsAgentId and ElevenLabsOrigin (wss://api.elevenlabs.io, or your residency host). Run it as a Windows scheduled task / service so it survives reboots.
Swap the echo for ElevenLabs
Section titled “Swap the echo for ElevenLabs”EchoBot’s audio seam is clean: SpeechService.AppendAudioBuffer(in) and an OnSendMediaBufferEventArgs(out) event. Replace its Azure-Speech body with an ElevenLabs agent WebSocket bridge that keeps the same surface:
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 });
}
}Both sides are PCM 16 kHz mono, so it’s a base64 passthrough — set the agent to pcm_16000. On an ElevenLabs interruption (barge-in), the bridge raises FlushMedia; wire that to your media stream so it drops any queued AudioMediaBuffers, otherwise the agent keeps talking over the caller. The full message reference is in the WebSocket docs. End-of-call hangup and warm transfer are covered in the sections below.
Step 4 — Make it callable in Teams
Section titled “Step 4 — Make it callable in Teams”-
Enable Calling on the Azure Bot’s Teams channel and set the calling webhook to
https://YOUR_FQDN/api/calling:Bash az bot msteams create -g $RG -n el-teams-agent-bot \ --enable-calling --calling-web-hook "https://YOUR_FQDN/api/calling"In the portal this lives at your Azure Bot resource → Channels → Microsoft Teams → Calling tab:
Azure Bot → Channels — the connected Microsoft Teams channel
Microsoft Teams channel → Calling — calling enabled with the bot's webhook
-
Build a Teams app manifest with
bots[0].supportsCalling: trueand the bot’s app ID, then sideload it (Apps → Manage your apps → Upload a custom app), or publish it org-wide without the UI:New-TeamsApp -DistributionMethod organization -Path ./bot-app.zip(MicrosoftTeams PowerShell module).
Search the app by name in Teams and call it — the bot answers and the ElevenLabs agent speaks.
A live 1:1 call with the agent — note Transfer and Consult in the call toolbar
Text chat (same bot)
Section titled “Text chat (same bot)”The same Azure Bot can also answer text in Teams — so users can either call the agent or chat with it. Calling and messaging are independent channels on the bot: the calling webhook handles voice, and a Bot Framework messaging endpoint (/api/messages) handles chat.
Chatting with the same bot in Teams
Point the bot’s messaging endpoint at whichever host serves it (the media bot, or any other service — it doesn’t have to be the Windows VM):
az bot update -g $RG -n el-teams-agent-bot --endpoint "https://YOUR_FQDN/api/messages"Implement the endpoint with the Bot Framework SDK and relay each message to the agent in text mode over the same conversation WebSocket used for voice — send a user_message event, read the agent_response event. First enable the first message field under the agent’s overrides settings — the code below overrides it to empty so the reply is the answer to the user’s message rather than the agent’s greeting:
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);
}Register it the standard way (a CloudAdapter, the bot via AddTransient<IBot, ChatBot>(), and a /api/messages controller), and add chat scopes to the manifest’s bot entry:
"bots": [
{ "botId": "YOUR_APP_ID", "supportsCalling": true, "scopes": ["personal", "team", "groupChat"] }
]End of call
Section titled “End of call”When ElevenLabs ends the conversation (its End Call tool closes the WebSocket), hang up the Teams leg:
await this.Call.DeleteAsync(); // after a short delay so the goodbye audio finishesWarm transfer to a human
Section titled “Warm transfer to a human”The agent fires a custom transfer_to_human client tool; the bot invites a Teams user into the live call (consultative add), then steps back:
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 botTroubleshooting
Section titled “Troubleshooting”MediaPlatform needs a system with at least 2 cores
The VM has only one physical core. Resize to ≥ 2 physical cores (e.g. D4s_v3) and restart.
Unable to load DLL 'NativeMedia'
Install the VC++ Redistributable (vcredist140) and the Server-Media-Foundation Windows feature, then restart the bot.
Incoming call returns 500 / call won't connect
The EchoBot port-null bug on 443 — patch HttpHelpers.SetAbsoluteUri (see Step 3). Also confirm the cert is CA-signed and reachable on 443.
Calling the bot says 'we couldn't connect you'
Confirm Calling is enabled on the Teams channel with the correct /api/calling webhook, the Graph Calls.AccessMedia.All permission is consented, and ports 443/8445/9441 are open on both the NSG and the Windows firewall. If calling used to work and stopped, check the bot process is still running on the VM — Task Scheduler’s default 72-hour execution limit kills it a few days after boot (see the warning in Step 3).