standin-sdk[livekit] answers Microsoft Teams calls with a LiveKit Agent.
StandIn answers the Microsoft Teams call and dials your worker. This plugin answers that dial, creates one LiveKit room per call, dispatches your own agent into it, and relays the audio both ways. By the time your entrypoint runs, the call is an ordinary LiveKit room: the caller’s voice is a room track like any other participant’s, so the session needs no special audio wiring.
Install
In your worker’s Python environment:livekit-agents. The example adds the model and VAD plugins it uses:
livekit-agents 1.6.10 or newer.
The two StandIn-specific lines
Out of a whole agent file, two lines are new:STANDIN_SECRET starts it. A worker without that variable behaves exactly as if the plugin were not installed, so the same file can keep serving your web and SIP rooms unchanged.
There is no bootstrap call. Importing registers the plugin with the worker, the same import-time registration every LiveKit plugin performs, and the call listener starts on the worker’s own worker_started event: once, in the main process, with the loop already running.
Why a listener at all: StandIn dials the worker per call, and no job exists until that dial has been answered and an agent dispatched. So the listener cannot live inside the entrypoint. It lives inside the worker’s lifecycle instead, where you never see it.
Configuration
Environment, unless you construct the handler yourself: see Advanced.
A missing LiveKit variable is caught at startup, with one clear log line, rather than on the first real call with a caller already on the line. A misconfigured plugin logs why it is not answering calls and lets the worker run; it never takes the worker down.
Then 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 it is the only page that does: three spellings of one URL across three pages is what caused a live incident here.
What you get in the entrypoint
TeamsCall().start(session, ctx=ctx) returns a CallInfo and wires two data topics onto your session.
Both topics carry
{"text": ...}, and the plugin’s handler is the only publisher. The names are exported as standin.TOPIC_CONTEXT and standin.TOPIC_GOODBYE.
on_goodbye= is the matching hook for the other topic, and it replaces the default rather than running beside it: pass one and the interrupt-then-speak behaviour above is yours to reproduce. Leave it unset unless the closing line needs to go somewhere other than the caller’s ear.
Both callbacks may be plain function or coroutine, and both run as the packet arrives rather than being awaited by anything. A handler that raises is logged and the call carries on, which is the right trade for a topic that is advisory: losing one context line must not end a call.
Context is queued until the agent is actually bound to the room, then published. The SDK itself does not queue, and that is right: what “ready” means is a framework’s own business. For LiveKit it means something specific, because a data packet reaches only the participants connected at that instant, and the first participants message arrives seconds before the dispatched agent joins.
The goodbye is not queued. The SDK has already told StandIn to drop the agent’s buffered audio and teardown follows in seconds, so a goodbye with no agent to hear it was never going to be spoken.
start() reads the caller off ctx and attaches the topic listener to ctx.room. Pass room= as well when the room you want the listener on is not ctx.room; it overrides the target and nothing else, so ctx is still what says who is calling. The record start() returned is also kept on the TeamsCall itself as .info, so a helper handed the object does not need the return value threaded to it.
CallInfo
CallInfo.from_job never raises. A job dispatched by anything else has no metadata, or metadata in someone else’s shape, and both read as “not a Microsoft Teams call” rather than taking the worker down. It looks in three places in order, the job’s own metadata, the job’s room snapshot and ctx.room, so it finds the record on the automatic-dispatch path too, including before ctx.room has connected.
TeamsCall().start() is the half that does raise. Handed a job this plugin did not dispatch it raises StandInError rather than attaching to a call that is not there, and it raises again if neither ctx= nor room= gives it a room. That is why the is_teams_call check above is a guard and not a nicety: in a worker that also serves web or SIP rooms, calling start() unguarded turns every non-Teams job into a failed entrypoint.
The two SDKs fill an unknown field differently, and an agent that reads the raw metadata will see it. This plugin always writes
caller_name, tenant_id and thread_id, substituting "caller" and "unknown-tenant" when StandIn does not know them, which is what the table above describes. The TypeScript plugin omits an empty field instead, so the same key is simply absent there. Reading through CallInfo hides the difference; reading job.metadata yourself does not.Dispatch
Withagent_name= set in @server.rtc_session(...), the plugin dispatches explicitly, which is the recommended setup. Without it, the plugin relies on automatic dispatch, where creating the room is itself what assigns the job. It logs which mode it is in at startup.
The name is read off the worker rather than configured twice, and it is resolved at worker_started rather than earlier, so the framework’s own precedence applies and the plugin cannot disagree with the worker about who it is. Two copies of a string that must match is how you get a room that is created, a job that never arrives, and a caller who hears silence.
Either way the plugin attaches the same call metadata to the room as it is created, so CallInfo reads it even on the automatic path, where jobs carry no dispatch metadata.
Rooms
Room names are{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 both SDKs compute it identically.
The prefix itself is configurable, and this is one of the few places the two SDKs differ in how: here it is the room_prefix argument on TeamsCallHandler, and in the TypeScript plugin it is the LIVEKIT_ROOM_PREFIX environment variable. Change it in one worker and the room a call lands in is no longer the room the other worker would have made, which is the point of writing the contract down.
At teardown the plugin disconnects and deletes the room, so the 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. If the delete fails it logs and lets the room idle out rather than failing the call. Turning it off is delete_room_on_end=False here, and LIVEKIT_DELETE_ROOM=off in the TypeScript plugin.
Draining and shutdown
The plugin hooks the LiveKit worker’s own drain: when the worker starts draining, the call listener stops accepting new dials while live calls continue. A draining worker must not answer a dial it will never dispatch, and the default LiveKit drain window is an hour. On shutdown it closes the listener before the worker finishes, cancelling a slow start first so a shutdown racing a start cannot bind the port after cleanup.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 own avatar. When it does not,express and send_speech_marks still drive StandIn’s avatar, which The avatar covers.
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, 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 tile extra: pip install "standin-sdk[tile]". Without it the relay stays off with one line in the log and the call is unaffected: the caller hears your agent and sees StandIn’s rendered avatar, which is what they would have seen anyway.
Advanced: constructing the handler yourself
TeamsCallHandler is exported for embedding without an AgentServer. It takes agent_name, the three livekit_* credentials, room_prefix, delete_room_on_end, tile_video and tile_video_fps. The last two are the constructor forms of the tile variables above, so a worker built this way sets them in code rather than in the environment. Normal workers never construct it: the plugin’s own factory does.
Full example
examples/livekit-msteams-connector is a complete, runnable worker with the .env layout, the tunnel command and the agent file.
Next
Call handler
What the plugin implements on your behalf.
Echo plugin
The template for a framework the SDK has no plugin for.
The avatar
The rest of the avatar surface: expression, visemes and your own frames on the tile.