Skip to main content
@komaa/standin-sdk/livekit puts a LiveKit agent on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, creates one room per call, dispatches your agent into it, and relays the audio both ways. The shape is the opposite of the provider plugins. Those are a socket you send audio to. LiveKit is a room you join: the caller is published as a track, and the agent’s audio is subscribed to. Your agent therefore needs no Microsoft Teams awareness at all. It sees a participant who talks.

Install and run

This is the one plugin with dependencies of its own, because joining a room means running LiveKit’s client.
Both packages are optional peers: they are imported only when this module is reached, so the core and every other plugin install without them. A missing one raises an error naming the install that fixes it, rather than a bare module-not-found from inside somebody else’s package. Expose port 9442 at the /msteams/calling path and register the public wss:// URL as your StandIn identity’s agent voice URL. Expose your agent carries the mount command and the probes, and there is a runnable example, in both languages, at examples/livekit-msteams-connector.

What your agent receives

Who is calling

Job metadata: source, call_id, call_direction, caller_name, user_id, tenant_id and thread_id.

Call context

Room data on two topics: msteams.context for participant counts, key presses and recording changes, and msteams.goodbye for the closing line.
Those key names are a cross-language contract, not a local choice, and so are the two topic names. source is always "msteams", and it is what Python’s CallInfo.from_job matches on: a job whose metadata is missing, unparseable or carries any other source reads as “not a Microsoft Teams call” and returns a blank record, rather than failing the worker. So an agent dispatched from this plugin and written in Python sees the caller without a line of translation, and an agent dispatched by something else is not mistaken for a caller. A field StandIn does not know stays absent here rather than becoming a shared default, so two anonymous callers never look like one person. There is no CallInfo helper on this side, and no entrypoint hook like the Python plugin’s TeamsCall().start(...), because there is no LiveKit worker in this process to hook into: your agent is a separate worker that this one dispatches into a room, so it reads the job metadata and subscribes to the two topics with LiveKit’s own API. In Python the agent and the plugin share a worker, which is what makes that helper possible there.
user_id is the caller’s AAD object id, and it is empty for guest and anonymous callers in both SDKs, so it is omitted here. Never use it as a bare key for per-caller memory without checking it first.
The Python plugin fills an unknown field differently. It always writes caller_name, tenant_id and thread_id, substituting "caller" and "unknown-tenant" when StandIn does not know them, where this plugin omits the key. An agent that reads job.metadata directly sees undefined under this plugin and a literal "caller" under the Python one, and one that reads through CallInfo sees an empty string here against "caller" there. Either way, test for a real name rather than for the key being present.

Dispatch

Set LIVEKIT_AGENT_NAME to the name your worker registered with and each call explicitly dispatches that agent into its room. Leave it unset and the room creation itself assigns the job, which is LiveKit’s automatic dispatch. Explicit is the recommended setup: a room that is created with nobody dispatched into it is a caller listening to silence.
The metadata above rides the explicit dispatch. This plugin attaches it to the RoomAgentDispatch it creates, so an agent picked up by automatic dispatch is handed no call metadata at all and sees no caller. The Python plugin additionally writes the same record as room metadata for exactly that path, so this is a real difference between the two, not a documentation gap. Set LIVEKIT_AGENT_NAME whenever your agent needs to know who is calling.

Rooms

Room names are {LIVEKIT_ROOM_PREFIX}{callId}, defaulting to msteams-, with the callId reduced to a conservative charset and the whole name capped at 100 characters. That derivation is a contract rather than a detail, so a room created for a given call is the same room whichever shipped plugin created it, and the Python plugin computes it identically.
The call id is sanitised before it becomes a room name: it arrives from a decoded URL segment, where a %2F would otherwise smuggle a slash into the name.
The prefix is one of the few places the two SDKs genuinely differ in how it is set: LIVEKIT_ROOM_PREFIX here, and the room_prefix constructor argument on the Python plugin’s TeamsCallHandler, which reads no environment variable for it. At teardown the plugin disconnects and deletes the room, so the dispatched agent job ends at once instead of idling out: a job whose room still exists sits there until LiveKit’s own empty-room timeout, which is minutes of a worker slot doing nothing. Set LIVEKIT_DELETE_ROOM=off to keep the room instead.

The avatar tile

If your agent publishes video of its own, an avatar worker or a rendered face, it is relayed onto the bot’s video tile automatically and on by default. The caller sees your agent rather than StandIn’s avatar. When it does not publish video, express and sendSpeechMarks still drive StandIn’s own avatar, and the caller hears the agent either way. LIVEKIT_TILE_VIDEO takes three kinds of value, and the third is the one worth knowing about: Pin an identity when a separate worker publishes the avatar and LiveKit’s publish-on-behalf attribute is not set: without a name the relay takes whichever participant published first, which on a busy room is the wrong one. With the attribute set there is nothing to pin, because that is the first thing the plugin looks at, falling back to the agent’s own identity. Tracks are matched by kind, never by source. An avatar worker publishes untagged video, so a source filter would pick the right participant and then stream nothing, which is the most expensive way to get this wrong. Frames are paced, latest-wins, and dropped rather than queued when the socket is congested, because both streams share one socket and a caller forgives a dropped frame far more readily than a break in the voice. LIVEKIT_TILE_VIDEO_FPS defaults to 12 and is clamped to MAX_TILE_FPS, which is 20: a talking head gains nothing above that, and a higher rate only spends local CPU on encoding. A value that is not a whole number above zero is refused when the configuration is read, naming the variable: an operator who typed twelve is looking at a setting that is not the one in force, and silence is what makes that take an afternoon to find. Encoding needs the optional sharp peer: npm install sharp. Without it the relay stays off with one line in the log and the call is unaffected. The Python plugin’s equivalent is the tile extra, pip install "standin-sdk[tile]".

Staying out of a meeting

A 1:1 call is simple: every turn is for your agent. A meeting is not. wakePhrases arms the SDK’s group gate from the call’s own thread:
It is empty by default, which leaves the gate inert, so an agent that answers everything keeps answering everything and nothing changes for it. handler.lastDecision reports what the gate decided about the caller’s last finished turn, and is undefined until a turn has been decided and on a call where transcripts never arrive. Group calls is the page for what the gate decides and why a partial transcript may notice a wake phrase but never decide a turn.

Inside your own worker

Read the configuration once, then close over it. handlerFactory runs once per call, so new LiveKitHandler() with nothing passed reads the environment again on every call, and a variable removed after startup would fail the next caller rather than failing you. serve(), which is what npx standin-livekit runs, does exactly this, then waits for SIGINT or SIGTERM before calling server.aclose(). server.start() returns as soon as the listener is bound, so a script that ends there exits before a single call arrives, and aclose() is what drains live calls and releases the port.

Draining and shutdown

This plugin is an ordinary CallHandler, so a deployment drains the way every other TypeScript worker does: set server.draining = true and new dials are refused with 503 while live calls continue. See CallServer. That is the one real difference from the Python plugin, which arms itself inside a LiveKit AgentServer and can therefore hook the worker’s own drain. There is no worker lifecycle to hook here, because there is no LiveKit worker in this process: your agent is a separate worker that this one dispatches into a room.

Writing the agent in Python instead

standin.plugins.livekit does the same thing and additionally arms itself from inside your worker, so your agent file keeps the shape of every other LiveKit example and nothing starts except through cli.run_app(server).

Configuration

A missing required variable is caught when the configuration is read, with one error naming the variable, rather than on the first real call with a caller already on the line.

Next

The avatar

The rest of the avatar surface: expression, visemes and your own frames on the tile.

Group calls

What the wake phrase gate decides, and what stops playback.