Agents and Dispatch
The bridge is agnostic about what your agent does - any LiveKit agent (Python or Node, any STT/LLM/TTS/realtime stack) works unchanged. There are only three integration points: how it is dispatched, the metadata it receives, and two data topics it can listen on.
Explicit dispatch
Section titled “Explicit dispatch”When LIVEKIT_AGENT_NAME is set, the bridge puts a RoomAgentDispatch in the join token’s RoomConfiguration. Because the bridge creates a fresh room per call, that token-based dispatch fires exactly when the room is created - your named agent is dispatched into that one room and no other.
Register the name on your worker:
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, agent_name="standin-agent"))LIVEKIT_AGENT_NAME=standin-agentThe names must match, or the agent never joins. A worker registered with agent_name is reachable only by explicit dispatch. If you set a name on the worker but leave LIVEKIT_AGENT_NAME unset, the bridge falls back to automatic dispatch, the named worker ignores it, and the call sits silent with no agent - the single most common setup mistake. Set LIVEKIT_AGENT_NAME to the worker’s exact agent_name.
Automatic dispatch (no name on either side; the agent joins every room) still works for a quick prototype, but LiveKit recommends explicit dispatch for anything real - otherwise every room in your project pulls in the agent.
Per-call metadata
Section titled “Per-call metadata”The dispatch carries JSON metadata, available in the agent’s job context (ctx.job.metadata in Python):
{ "source": "msteams", "caller_name": "Jane Caller", "tenant_id": "<tenant guid>", "call_direction": "inbound", "user_id": "<AAD object id, only when Teams provides one>"}Nullable Teams fields are defaulted, never null: caller_name falls back to "caller", tenant_id to "unknown-tenant". user_id is included only when Teams supplies an AAD id, so it is per-person and never a shared placeholder - safe to use as a personalization or lookup key.
async def entrypoint(ctx: JobContext): meta = json.loads(ctx.job.metadata or "{}") greeting = f"Hello {meta.get('caller_name', 'there')}, you're calling from Teams." # ... build your AgentSession as usualData topics
Section titled “Data topics”The bridge publishes into the room on three topics. Subscribe to them if your agent should react to call context, the governor, or what the caller is showing on screen.
msteams.context
Section titled “msteams.context”Non-interrupting context about the call, as { "text": "..." }:
- Participant count changes -
"This is a 1:1 call with a single human caller."or, in a meeting, the roster line plus the GROUP-CALL ETIQUETTE clause naming the agent’s wake phrases (see Configuration Reference). - Speaker changes in a meeting -
"The person now speaking is Sara." - DTMF -
"The caller pressed the \"5\" key on their keypad." - Recording state changes.
Feed these into your agent as system/context messages so it can adapt. The etiquette clause is the primary mechanism of the group-call gate - the agent owns turn-taking on this transport, so an agent that reads and honours the clause is what produces good meeting behaviour. The bridge’s audio-egress check behind it is a deterministic backstop, not a substitute.
msteams.vision (opt-in)
Section titled “msteams.vision (opt-in)”With AMBIENT_VISION=true, each changed screen-share or camera frame arrives as a byte stream (not a data packet - an image does not fit in one). The image bytes are exactly what Teams sent; the attribution rides in the stream attributes, so a handler never has to look at the picture to know whose screen it is.
def on_vision(reader, participant): async def read(): image = b"".join([chunk async for chunk in reader]) attrs = reader.info.attributes # source, owner, caption, width, height, ts # e.g. attrs["owner"] == "Sara's shared screen", reader.info.mime_type == "image/jpeg" ... asyncio.create_task(read())
ctx.room.register_byte_stream_handler("msteams.vision", on_vision)Nothing about this makes the agent speak: a delivered frame is context for its next natural turn.
msteams.goodbye
Section titled “msteams.goodbye”The governor’s goodbye line, as { "text": "..." }. When a call hits its time limit, the bridge asks the agent to speak this text, waits GOODBYE_GRACE_MS, then ends the call. There is no bridge-side TTS on the room transport - the agent speaks the goodbye. Have your handler interrupt the current turn so the goodbye actually plays:
@ctx.room.on("data_received")def on_data(packet): if packet.topic == "msteams.goodbye": text = json.loads(packet.data)["text"] session.interrupt() # stop the current turn session.say(text, allow_interruptions=False)See Governors and Privacy for the full governor behavior.
Avatar agents
Section titled “Avatar agents”Avatar agents (bitHuman, Tavus, and others) publish synchronized audio and video. The caller hears the avatar’s audio - the bridge relays whichever remote track carries the agent’s voice, including an avatar’s republished audio.
Two things to know for v1:
- The avatar’s video stays in the room. The Teams tile is rendered by StandIn’s own animated avatar (RMS lip-sync), not the room video. Bridging room video to the Teams tile is on the roadmap.
- Avatar setups often run the avatar as a separate participant alongside the agent session. The bridge tracks the agent identity and only ends the call when that participant leaves, so a flapping avatar participant will not cut a healthy call short.
Ready-made examples (a minimal voice agent and a bitHuman avatar variant) live in examples/agents/.