@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.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.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.
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
SetLIVEKIT_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.
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.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:
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
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 ordinaryCallHandler, 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.