Skip to content

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.

When LIVEKIT_AGENT_NAME is set, the bridge creates the per-call room and then creates an explicit agent dispatch for it via LiveKit’s AgentDispatch service (create_dispatch - the documented pattern). Because the bridge creates a fresh room per call, 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"))
Terminal window
LIVEKIT_AGENT_NAME=standin-agent

The 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.

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 usual

The bridge publishes two reliable data topics into the room. Subscribe to them if your agent should react to call context or the governor.

Non-interrupting context about the call, as { "text": "..." }:

  • Participant count changes - "This is a 1:1 call with a single human caller." or "There are N human participants on this call. Stay quiet unless directly addressed."
  • DTMF - "The caller pressed the \"5\" key on their keypad."
  • Recording state changes - "The Microsoft Teams call recording is now ACTIVE." (and the inverse), so the agent can disclose or adjust.

Feed these into your agent as system/context messages so it can adapt (for example, stay quiet in a group call until addressed).

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 == "teams.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.

The bridge binds “the agent” by participant kind (PARTICIPANT_KIND_AGENT): a monitor, recorder or debugging participant that happens to publish audio first can neither be mistaken for the agent nor block the agent’s track. Only when the participant kind is unavailable (automatic-dispatch prototypes) does it fall back to first-audio-wins. Only the bound agent leaving ends the call.

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 (the audio pump re-arms when a track is unpublished and re-published).

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/ - they work with either the Node or the Python bridge.