Skip to main content
The repo ships example projects so you can see a full working setup - an agent and the bridge - before wiring your own. A LiveKit call needs both: your agent runs as a worker, and the bridge dispatches it into a per-call room. Budget about 15 minutes once the Teams setup is done.

examples/voice-agent

A minimal LiveKit voice agent (OpenAI STT/LLM/TTS + silero VAD). worker.py, registers as standin-voice-agent.

examples/video-agent

The same pipeline plus a lip-synced bitHuman avatar. worker.py, registers as standin-avatar-agent.

examples/basic-bridge

Embed the bridge in your own Node project: loadConfig() + startServer(), env-file config, graceful shutdown.

What you need first

  • Node.js >= 20 (bridge) and Python 3.10+ with uv or pip (agent).
  • A LiveKit server - a LiveKit Cloud project or self-hosted - with its URL, API key, and API secret.
  • An OpenAI API key (the example agent uses OpenAI STT/LLM/TTS).
  • A StandIn identity with its shared secret (from pairing or the dashboard). The sandbox works too if you have no Teams bot yet.

Set up the LiveKit project and agent

Unlike a hosted agent, a LiveKit “agent” is a worker you run - examples/voice-agent (and examples/video-agent for the avatar) is a complete, ready-to-run one. You need two things: a LiveKit project for it to connect to, and an agent name so the bridge can dispatch it.

Create a LiveKit project

Sign in to LiveKit Cloud (or run a self-hosted server) and create a project. From its settings, copy three values - they go in both the agent’s .env (step 1) and the bridge (step 2):
The agent worker and the bridge must point at the same LiveKit project. If they use different projects, dispatch never reaches the agent and the caller hears silence - the single most common setup mistake.

The agent name is the contract

The bridge dispatches by agent name (explicit dispatch). The example voice agent registers itself as standin-voice-agent:
That name must equal the bridge’s LIVEKIT_AGENT_NAME (step 2). Nothing else about the agent is Teams-specific - and unlike a hosted agent, there is no audio-format setting to get right, because the bridge and the LiveKit SDK resample to 16 kHz for you.

Use your own agent instead

Any existing LiveKit agent works - give its worker an agent_name, point it at the same project, and run it in place of the example:
Read the caller’s details from ctx.job.metadata and react to the teams.context / teams.goodbye data topics if you want (all shown in the example agents). See Agents and dispatch.

1. Run the voice agent

Clone the repo and enter the voice-agent example:
Fill in the agent’s .env: Then install and start the worker:
(Plain pip works too: pip install -r requirements.txt && python worker.py dev. Use start instead of dev in production.) The worker registers with your LiveKit server under the agent name standin-voice-agent and waits for dispatch - it will not join anything until the bridge creates a room.

2. Run the bridge

In a second terminal, point the bridge at the same LiveKit project and at that agent name:
LIVEKIT_AGENT_NAME must equal the agent_name the worker registers with (the examples use standin-voice-agent and standin-avatar-agent). A mismatch is the classic silent failure: the room is created, the caller hears nothing, and the worker never gets a job.
The bridge listens on ws://<host>:9442/msteams/calling/{callId} (PORT and WS_PATH move the port and the base path). It has no chat lane - just this WebSocket, plus /healthz and /metrics at the root.

3. Expose the calling path

StandIn connects from the internet, so the endpoint needs a public wss:// URL. Any tunnel works:
See Expose your agent, the LiveKit / ElevenLabs tab: one mount for /msteams/calling on port 9442, and no /api/messages because this bridge is voice only.Your URL, with no port: wss://<machine>.<tailnet>.ts.net/msteams/calling
Verify locally with curl http://127.0.0.1:9442/healthz (returns ok). A plain GET to /msteams/calling answers 404 even on a healthy bridge - only a WebSocket upgrade is routed - and through a Funnel you must add --http1.1, or curl’s upgrade headers are ignored over HTTP/2 and you get the same misleading 404.

4. Connect it to StandIn and call

  1. In your StandIn dashboard, set the identity’s Agent calling URL to the wss:// URL from step 3. Leave the Agent messages URL empty.
  2. Make sure the identity’s shared secret equals BRIDGE_SECRET.
  3. Call your Teams bot (or join the sandbox meeting). StandIn joins, connects to the bridge, the bridge creates a room and dispatches the agent, and the agent answers.

Swap in the avatar agent

examples/video-agent is the same pipeline plus a lip-synced bitHuman avatar on the bot’s video tile. Its .env needs two extra variables: Run it the same way - from examples/video-agent, uv run worker.py dev - and restart the bridge with LIVEKIT_AGENT_NAME=standin-avatar-agent. The avatar’s face also shows on the Teams tile by default - the bridge relays the agent’s avatar video onto the caller’s tile (LIVEKIT_TILE_VIDEO=auto, the default). Set LIVEKIT_TILE_VIDEO=off for audio-only, and the tile shows StandIn’s built-in animated avatar instead. See Configuration -> Avatar video.

How the pieces talk

Your agent needs no Teams-specific code, but three integration points are available:
  • agent_name in WorkerOptions - must match the bridge’s LIVEKIT_AGENT_NAME for explicit dispatch.
  • ctx.job.metadata (JSON) - per-call context: source, caller_name, tenant_id, call_direction, and user_id (AAD id when Teams provides one).
  • Data topics - teams.context (participant count, DTMF) and teams.goodbye (the governor’s goodbye line; have your handler speak it and interrupt the current turn).
Details: Agent dispatch and Agents and dispatch on the project site.

Docker

Each agent folder ships a Dockerfile; pass the environment at runtime rather than baking it in. From examples/voice-agent:
The avatar (examples/video-agent) additionally needs the model mounted: docker build -f Dockerfile -t standin-avatar-agent . && docker run --env-file .env -v ./avatar.imx:/models/avatar.imx -e BITHUMAN_MODEL_PATH=/models/avatar.imx standin-avatar-agent.

From example to your own project

Embed the bridge with npm install @komaa/livekit-msteams-bridge and start from examples/basic-bridge (loadConfig() + startServer()); keep your own agent worker as is - any LiveKit agent works unchanged. The library API documents the programmatic surface.

If something does not work

  • WebSocket rejected with 401 - BRIDGE_SECRET does not match the secret in StandIn, or the registered URL is missing the /msteams/calling path.
  • Room is created but no agent joins - LIVEKIT_AGENT_NAME does not match the worker’s agent_name, or the worker is not running / registered with a different LiveKit project.
  • Bot joins the call but stays silent - the Agent calling URL is unreachable from the internet; re-check the tunnel. Probing it with curl needs --http1.1, and a plain GET returns 404 even when the mount is fine.
  • More: Troubleshooting.

Examples README

Configuration

Library API