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 name is the contract
The bridge dispatches by agent name (explicit dispatch). The example voice agent registers itself asstandin-voice-agent:
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 anagent_name, point it at the same project, and
run it in place of the example:
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:.env:
Then install and start the worker:
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: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 publicwss:// URL. Any tunnel
works:
- Tailscale Funnel
- cloudflared
- ngrok
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/calling4. Connect it to StandIn and call
- In your StandIn dashboard, set the identity’s
Agent calling URL to the
wss://URL from step 3. Leave the Agent messages URL empty. - Make sure the identity’s shared secret equals
BRIDGE_SECRET. - 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_nameinWorkerOptions- must match the bridge’sLIVEKIT_AGENT_NAMEfor explicit dispatch.ctx.job.metadata(JSON) - per-call context:source,caller_name,tenant_id,call_direction, anduser_id(AAD id when Teams provides one).- Data topics -
teams.context(participant count, DTMF) andteams.goodbye(the governor’s goodbye line; have your handler speak it and interrupt the current turn).
Docker
Each agent folder ships aDockerfile; pass the environment at runtime rather than baking it in. From
examples/voice-agent:
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 withnpm 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_SECRETdoes not match the secret in StandIn, or the registered URL is missing the/msteams/callingpath. - Room is created but no agent joins -
LIVEKIT_AGENT_NAMEdoes not match the worker’sagent_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 plainGETreturns404even when the mount is fine. - More: Troubleshooting.