@komaa/livekit-msteams-bridge
v0.3.0
Published
Bridge Microsoft Teams voice calls to a LiveKit Agent. Terminates the StandIn media-bridge wire protocol on one side and joins a LiveKit room (with agent dispatch) on the other.
Maintainers
Readme
Microsoft Teams Bridge for LiveKit Agents
@komaa/livekit-msteams-bridge puts a LiveKit Agent on a real Microsoft Teams call - including avatar agents (bitHuman, Tavus, ...) whose voice the caller hears in Teams.
The hosted StandIn media bridge (standin.komaa.com) joins the Teams call and dials into this bridge over an HMAC-authenticated WebSocket. Per call, the bridge creates one LiveKit room, dispatches your agent into it (explicit dispatch by agentName), joins as a participant, publishes the caller's audio, and relays the agent's audio back to Teams.
Microsoft Teams call
|
v
StandIn media bridge (hosted; joins the call)
| HMAC WebSocket, PCM 16 kHz
v
this bridge (you run it)
| WebRTC (room, one per call)
v
LiveKit room <--dispatch-- your LiveKit Agent
(STT + LLM + TTS + turn-taking, any plugin stack)Both sides speak 16 kHz mono PCM16: the wire protocol natively, the room via the SDK's resampling AudioSource/AudioStream - the bridge itself never transcodes.
Features
- Any LiveKit agent answers Teams calls - your existing agent (Python or Node, any STT/LLM/TTS/realtime plugin combo) needs no Teams-specific code. The bridge dispatches it by
agentNamewith per-call metadata (caller name, tenant, direction, AAD id when known). - One room per call - clean lifecycle: room created at
session.start, agent dispatched via the join token (RoomConfiguration), room deleted at teardown so the agent job ends immediately. - Turn-taking is the agent's own - VAD, interruption, and endpointing all run inside your LiveKit agent session, exactly as they do for WebRTC users.
- Group-call awareness - participant counts and DTMF digits reach the agent as data messages on the
teams.contexttopic. - Two call governors - a StandIn-side cutoff (the bridge forwards the goodbye request on
teams.goodbye) and a bridge-sideMAX_CALL_MINUTEShard cap. - Hardened transport (ported from the proven
@komaa/elevenlabs-msteams-bridge): replay-proof single-use HMAC upgrade, connection caps checked before crypto, payload caps, backpressure bounds with control-frame exemption, pre-start timeout that only a realsession.startclears, dead-peer detection (90 s), duplicate-call 409, pre-auth crash guard, graceful SIGTERM drain. - Observability -
GET /healthzandGET /metrics(Prometheus text format): calls, durations, rejects, relay/drop counters.
Install
npx @komaa/livekit-msteams-bridge
# or
npm install @komaa/livekit-msteams-bridgeNode.js >= 20. Runtime deps: ws, livekit-server-sdk, @livekit/rtc-node (native).
Quick start
1. Prepare the agent
Any LiveKit agent works. Register it with an explicit agent name so the bridge can dispatch it:
# Python (agents >= 1.0): explicit dispatch by name
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, agent_name="my-teams-agent"))Per-call metadata arrives in the job context (ctx.job.metadata, JSON):
{"source":"msteams","caller_name":"...","tenant_id":"...","call_direction":"inbound","user_id":"<aad-id, when known>"}.
Optional: subscribe to the bridge's data topics -
teams.context (participants/DTMF as {text}) and teams.goodbye ({text} the agent should speak before the call is cut).
Avatar agents (e.g. the bitHuman example): the caller hears the avatar's synchronized audio. The avatar's video stays in the room in v1 - the Teams tile is rendered by StandIn's own animated avatar (RMS lip-sync). Bridging room video to the Teams tile is on the roadmap.
2. Run the bridge
LIVEKIT_URL=wss://your-project.livekit.cloud \
LIVEKIT_API_KEY=API... \
LIVEKIT_API_SECRET=... \
LIVEKIT_AGENT_NAME=my-teams-agent \
WORKER_SHARED_SECRET=... \
npx @komaa/livekit-msteams-bridgeOr as a library:
import { loadConfig, startServer } from "@komaa/livekit-msteams-bridge";
startServer(loadConfig()); // env-configured; see .env.example3. Connect it to StandIn
Pick a tier at standin.komaa.com, pair an identity, then:
- Point the identity's agent WebSocket URL at this bridge (e.g.
wss://lk-bridge.example.com:8080/voice/msteams/stream; StandIn appends/{callId}per call). - Set
WORKER_SHARED_SECRETto the pairing secret (both sides must match or the handshake is rejected with 401). - Call your Teams bot. StandIn joins, dials the bridge, the bridge creates the room and dispatches your agent, and the agent answers.
Examples
examples/basic-bridge/- embed the package in your own Node project (npm install @komaa/livekit-msteams-bridge, three lines of code).examples/agents/- two ready-made Python agents the bridge can dispatch: a minimal voice pipeline and a bitHuman avatar variant, both showing the three Teams integration points (agent_name,ctx.job.metadata, theteams.*data topics).
Configuration
See .env.example (ships with the package). Notable:
LIVEKIT_AGENT_NAME- explicit dispatch (LiveKit's recommended model). Unset = automatic dispatch (agent joins every room; prototype-only).LIVEKIT_DELETE_ROOM_ON_END(defaulttrue) - delete the room at teardown so the agent job ends immediately instead of idling out (billing hygiene).MAX_CALL_MINUTES/GOODBYE_TEXT/GOODBYE_GRACE_MS- the bridge-side governor. There is no bridge-side TTS on the room transport: the goodbye is ateams.goodbyedata message your agent should speak, and the grace covers the unknown duration.- Transport hardening knobs:
MAX_CONNECTIONS,MAX_CONNECTIONS_PER_IP(+TRUST_PROXY_XFFbehind a proxy),PRE_START_TIMEOUT_MS,WORKER_IDLE_TIMEOUT_MS,HMAC_FRESHNESS_MS. - TLS: the bridge serves plain WS - front it with a TLS terminator (tunnel / ingress / LB).
Known limitations (v1)
- Barge-in flush: interruption handling runs inside the LiveKit agent (as designed), but the room emits no interruption event the bridge could map to the wire protocol's
assistant.cancel- so up to ~1 s of already-relayed agent audio can play out after the caller cuts in. Acceptable in practice; an agent-published data event could close this later. - Video: caller video/screenshare frames are not published into the room, and room video (avatar agents) is not bridged to the Teams tile.
- No deterministic goodbye: the governor's goodbye is spoken by the agent (
teams.goodbyedata topic), not synthesized by the bridge. The bridge flushes Teams-side playback first (assistant.cancel), but whether the agent interrupts its own in-flight sentence to speak the goodbye is the agent's choice - if its current turn outlastsGOODBYE_GRACE_MS, the goodbye gets cut. Have theteams.goodbyehandler interrupt the current turn (see the example agents). - Reconnects: the LiveKit SDK retries transient drops internally (reconnecting/reconnected);
Disconnectedis final and ends the Teams call. There is no bridge-level room re-join beyond that.
Security notes
GET /healthzandGET /metricsare unauthenticated and served on the same port StandIn dials. Restrict the port at the network layer (or scrape through your ingress); the metrics expose only counters, never call content.TRUST_PROXY_XFFtakes the FIRSTX-Forwarded-Forhop, which is only trustworthy behind a single proxy that OVERWRITES the header (appending proxies make it client-controlled). Leave it off otherwise.- The default per-IP cap equals the global cap (no per-IP throttle) because legitimate traffic arrives from StandIn's small, fixed egress set - a small per-IP default would cap total concurrent calls. Set
MAX_CONNECTIONS_PER_IPexplicitly if your bridge is exposed more broadly.
Layout
src/
server.ts HTTP + WS upgrade, HMAC + replay guard, caps, drain (ported hardening)
session.ts per-call relay: worker WS ⇄ LiveKit room, governors, dead-peer
livekit.ts room join, agent dispatch (token RoomConfiguration), AudioSource/AudioStream
protocol.ts worker wire types (shared with the sibling bridges)
hmac.ts HMAC-SHA256("{timestampMs}.{callId}") sign/verify
metrics.ts Prometheus counters (/metrics)
config.ts env config (fail-loud numerics)
test/ node:test suites; a FakeRoom stands in for LiveKit (no network, no native module)