> ## Documentation Index
> Fetch the complete documentation index at: https://docs.komaa.com/llms.txt
> Use this file to discover all available pages before exploring further.

# LiveKit plugin

> Answer Microsoft Teams calls with a LiveKit agent, using the StandIn TypeScript SDK.

`@komaa/standin-sdk/livekit` puts a [LiveKit agent](https://docs.livekit.io/agents/) 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.

```bash theme={null}
npm install @komaa/standin-sdk @livekit/rtc-node livekit-server-sdk
```

```bash theme={null}
export STANDIN_SECRET=...       # your StandIn connection secret
export LIVEKIT_URL=wss://...
export LIVEKIT_API_KEY=...
export LIVEKIT_API_SECRET=...
export LIVEKIT_AGENT_NAME=standin-msteams
npx standin-livekit
```

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](/expose) carries the mount command and the probes, and there is a runnable example, in both languages, at [examples/livekit-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/livekit-msteams-connector).

## What your agent receives

<CardGroup cols={2}>
  <Card title="Who is calling" icon="user">
    Job metadata: `source`, `call_id`, `call_direction`, `caller_name`, `user_id`, `tenant_id` and `thread_id`.
  </Card>

  <Card title="Call context" icon="circle-info">
    Room data on two topics: `msteams.context` for participant counts, key presses and recording changes, and `msteams.goodbye` for the closing line.
  </Card>
</CardGroup>

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`](/python-sdk/plugins/livekit#callinfo) 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.

<Warning>
  `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.
</Warning>

<Note>
  **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.
</Note>

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

<Warning>
  **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.
</Warning>

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

<Note>
  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.
</Note>

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`](/typescript-sdk/avatar) 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:

| Value           | What happens                                         |
| --------------- | ---------------------------------------------------- |
| unset or `auto` | Relay whichever participant's video the room offers. |
| `off`           | No relay. The caller sees StandIn's avatar.          |
| anything else   | A participant **identity** to pin the relay to.      |

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:

```ts theme={null}
new LiveKitHandler({ wakePhrases: ["assistant", "standin"] });
```

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](/typescript-sdk/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

```ts theme={null}
import { CallServer } from "@komaa/standin-sdk";
import { LiveKitHandler, liveKitConfigFromEnv } from "@komaa/standin-sdk/livekit";

const config = liveKitConfigFromEnv();
const server = new CallServer({
  handlerFactory: () => new LiveKitHandler({ config }),
});
await server.start();
```

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](/typescript-sdk/call-server#draining).

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`](/python-sdk/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

| Variable                 | Default            | What it is                                                                                                                                               |
| ------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `STANDIN_SECRET`         | *(required)*       | Connection secret from the StandIn portal.                                                                                                               |
| `STANDIN_PORT`           | `9442`             | Port the call listener binds.                                                                                                                            |
| `STANDIN_HOST`           | `0.0.0.0`          | Bind address. Use `127.0.0.1` when only a local tunnel should reach it.                                                                                  |
| `STANDIN_WS_PATH`        | `/msteams/calling` | Path StandIn dials.                                                                                                                                      |
| `LIVEKIT_URL`            | *(required)*       | Your project's `wss://` URL.                                                                                                                             |
| `LIVEKIT_API_KEY`        | *(required)*       | Project key.                                                                                                                                             |
| `LIVEKIT_API_SECRET`     | *(required)*       | Project secret. Never logged, never sent to StandIn.                                                                                                     |
| `LIVEKIT_AGENT_NAME`     | *(unset)*          | The name your worker registered with. Unset means automatic dispatch.                                                                                    |
| `LIVEKIT_ROOM_PREFIX`    | `msteams-`         | Prepended to the call id to name the room.                                                                                                               |
| `LIVEKIT_DELETE_ROOM`    | *(on)*             | Set to `off` to keep the room at teardown instead of deleting it.                                                                                        |
| `LIVEKIT_TILE_VIDEO`     | `auto`             | Relay your agent's own video onto the bot's tile. See [The avatar tile](#the-avatar-tile).                                                               |
| `LIVEKIT_TILE_VIDEO_FPS` | `12`               | Frames per second for that relay. Clamped to `MAX_TILE_FPS`, which is 20. A value that is not a whole number above zero is refused, naming the variable. |

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

<CardGroup cols={2}>
  <Card title="The avatar" icon="face-smile" href="/typescript-sdk/avatar">
    The rest of the avatar surface: expression, visemes and your own frames on the tile.
  </Card>

  <Card title="Group calls" icon="users" href="/typescript-sdk/group-calls">
    What the wake phrase gate decides, and what stops playback.
  </Card>
</CardGroup>
