Skip to main content
ElevenLabs Documentation Docs

Search documentation

Type to search this documentation.

On this pageOverview

Graph calling bot

Call or chat with your ElevenLabs agent by name inside Microsoft Teams, like a colleague.

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.

A Teams user calls the bot by name; Teams routes the call to the media bot on a Windows VM, which bridges raw PCM 16k audio to the ElevenLabs agent over a WebSocket

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.

  1. An Azure Bot registration + app (Entra app registration).
  2. Graph application permissions with admin consent: Calls.AccessMedia.All (raw media) plus Calls.Initiate.All.
  3. A Windows Server VM (≥ 2 physical cores — e.g. Standard_D4s_v3) with a public IP and open media ports.
  4. A CA-signed TLS certificate on a public FQDN for the media/signaling endpoint (the media platform rejects self-signed certs).
  5. 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.
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):

Bash
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

Grant the two Graph application roles and admin consent (needs Global Admin / Privileged Role Admin), then confirm the assignments landed:

Bash
# 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

In the portal, verify in the Entra admin center under App registrations → your app → API permissions: both permissions should show Granted with green checks.

The app registration API permissions blade showing Calls.AccessMedia.All and Calls.Initiate.All granted

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”
Bash
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

On the VM (the media platform’s native code needs these — Windows Server lacks them by default):

powershell
# 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 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.

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):

powershell
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 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.

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:

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

  1. 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:

    The Azure Bot Channels blade listing the Microsoft Teams channel as healthy

    Azure Bot → Channels — the connected Microsoft Teams channel

    The Teams channel Calling tab with Enable calling checked and the calling webhook set

    Microsoft Teams channel → Calling — calling enabled with the bot's webhook

  2. Build a Teams app manifest with bots[0].supportsCalling: true and 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.

An active Teams call with the ElevenLabs agent bot

A live 1:1 call with the agent — note Transfer and Consult in the call toolbar

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.

A Teams chat with the ElevenLabs agent bot answering text messages

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):

Bash
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:

ChatBot.cs — Teams text chat -> ElevenLabs (text mode)
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:

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

When ElevenLabs ends the conversation (its End Call tool closes the WebSocket), hang up the Teams leg:

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

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:

C#
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
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).

Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu