CallServer answers the socket StandIn dials, authenticates it, speaks the wire protocol, and hands each call to a handler your plugin supplies. Everything it owns is the same whichever agent framework is on the other side, which is exactly why it lives in the SDK and not in the plugins.
What it owns
- the HMAC handshake, its freshness window and its single-use replay guard
- capacity, draining, and the one-live-session-per-
callIdrule - the frame loop: inbound
session.start,audio.frame,video.frame,ping,participants,dtmf,recording.status,assistant.sayandsession.end, and outboundaudio.frame,assistant.cancel,expression,speech.marks,display.image,display.frame,pongandsession.end - outbound sequence numbers and the audio timeline, for the tile as well as the voice
- five timers: the pre-start watchdog, the caller-audio idle watchdog, the
on_starttimeout, the stale-call reaper and the optional call-duration ceiling - backpressure on outbound audio, so a peer that stops reading cannot wedge the call
GET /healthz, the per-call upgrade route, and the outcome route when you ask for one- idempotent, shielded teardown that always frees the slot
video.frame is parsed and stored on the way in.
The server keeps the latest video frame per source whether or not your handler implements on_video_frame, because a plugin that only looks when the model asks implements no callback at all.
Constructor
handler_factory
Builds one handler per call, called with no arguments. This is the plugin. A non-callable raisesStandInError at construction, not at the first call, because a caller is already on the line by then.
secret
The connection secret from the StandIn portal. It must byte-match, or the handshake is rejected with 401. Falls back toSTANDIN_SECRET, and an empty secret raises StandInError rather than starting a listener that would accept nothing.
host, port, ws_path
Where the listener binds. The defaults match the StandIn plugin layout,0.0.0.0:9442 at /msteams/calling, because the worker usually runs in a container behind an ingress. Bind 127.0.0.1 when only a local tunnel should reach the listener. The upgrade is HMAC-authenticated either way.
ws_path is normalized to a single leading slash with no trailing slash. A path that reduces to / raises StandInError: the route has to have a real path, because the per-call callId is appended to it as {ws_path}/{callId}.
The server also serves GET /healthz, which returns {"ok": true, "calls": <n>}.
max_connections
Concurrent live calls. Checked before any crypto runs, so a flood cannot make the worker spend CPU verifying signatures for calls it was never going to accept. Over the limit, the upgrade gets503 at capacity.
pre_start_timeout
Seconds a socket may stay silent after authenticating before it is dropped for never sendingsession.start.
This defends against a slot leak: a socket that authenticates and then says nothing holds a connection slot and a callId that nothing will ever free, so every retry for that call would get 409 forever. The close reason is pre-start-timeout.
It bounds the arrival of session.start only. Once that frame is received, on_start_timeout owns the startup budget.
audio_idle_timeout
Seconds without caller audio before a live call is declared dead and torn down.0 disables it.
A live Microsoft Teams call delivers PCM continuously, and silence is still frames, so audio going quiet for this long means the call is gone on the far side and nobody said so. That happens: the peer keeps the socket open, and can even keep pinging, while its own teardown is wedged. Without this backstop the handler’s session burns until someone notices. The close reason is caller-idle-timeout.
The watchdog is armed before on_start runs, not after, so a hung on_start is covered by it too.
An outbound call that nobody has picked up yet is skipped, because a ringing call carries no caller audio by definition and would otherwise look exactly like a dead one.
on_start_timeout
Seconds a handler’son_start may take before the call is given up. 0 disables it.
on_start does real network work: LiveKit joins a room and dispatches an agent, a realtime plugin opens a provider socket. The frame loop is suspended while it runs, so session.end is not even read, and an unbounded on_start holds a slot for the life of the worker. Exceeding it closes the call as handler-start-timeout; raising inside it closes as handler-start-failure.
Those two reasons are distinct from transport-failure on purpose. Reporting a plugin’s problem as StandIn’s own socket failing sends both sides debugging the wrong system.
The stale-call reaper
stale_call_reaper_seconds is 120 seconds a call may run with nothing having answered it. Set 0 to disable it. Four of the five bounds on this page are on by default; max_call_seconds is the one that is not.
It covers the gap none of the other timers do. pre_start_timeout was watching for a session.start that arrived. on_start_timeout bounded an on_start that succeeded. audio_idle_timeout is satisfied, because the caller is still talking. So StandIn is on the call, the caller hears nothing, and no other timer will ever fire. An agent dispatch that never lands looks exactly like this, and it is the commonest misconfiguration there is.
The close reason is no-agent-answered. The clock starts when the socket connects, and the check is coarse on purpose: this is a grace period, not something anybody measures to the second.
Sending audio counts as answering, so most plugins are covered without doing anything. A plugin that answers by a route this server cannot see, joining a room and letting an agent speak there, calls session.mark_answered() when the agent’s own audio track appears. Documenting the reaper without that method would be describing a timer with no way to satisfy it.
The call-duration ceiling
max_call_seconds is a hard ceiling on one call, measured from session.start. It is off by default, because a ceiling is a policy rather than a safety net.
It is different from audio_idle_timeout, and the difference is the whole point. That watchdog ends a call that went quiet. This one ends a call that has not: a caller who will not hang up, a model looping at itself, an automated system that dialled and never stopped talking. Each of those bills a provider by the minute for as long as the socket lives, and none of them trips a silence check.
When the ceiling is reached the server flushes playback first, then hands goodbye_text to your handler’s on_goodbye, the same callback StandIn’s own closing line uses, so a plugin needs no new code to honour it. goodbye_grace bounds how long that line gets: after it the call ends whether or not anything was said, because a plugin that hangs in on_goodbye must not turn a time-limited call into an endless one. The close reason is call-duration-limit, and it arrives after on_goodbye has already fired.
Outbound call outcomes
on_call_outcome is called with (call_id, outcome) when StandIn reports how an outbound call ended without anyone answering. It is the only signal that nobody picked up, and without it an unanswered call waits out the ring timer before anything can be said about it.
POST {ws_path}/outcome/{callId}. Leave it unset and a POST there is a 404, so a worker that never places a call opens no extra surface.
The route sits under ws_path, so the /msteams/calling mount you already have covers it and there is no second tunnel command to run. See Expose your agent.
Four things about the verification are worth knowing:
- It is signed with
X-StandIn-Signature-V2over the method, the path and a hash of the body, so a signature captured for one route is useless against another. - The body is read before the signature can be checked, because the signature covers a hash of it. That is why the body is capped at 8 KB first: the cap is the bound on what an unauthenticated peer can make the worker read.
- Freshness is checked in the 60-second handshake window, not the longer body window.
- Over the cap is
413, a bad signature or a stale timestamp is401, and a good one is204. A plugin that raises inside the callback is logged and the request is still acknowledged, because a failing outcome handler must not turn into a retry loop.
OutboundLane reads this to sweep an unanswered call. See Reaching people.
Backpressure
Outbound audio is shed, not queued. When the transport has more thanMAX_AUDIO_BUFFER_BYTES (1 MB) unflushed, send_audio advances the sequence number and the timeline and returns without sending, and logs at most once every five seconds.
session.buffered_bytes before pushing a continuous stream. The avatar tile has its own, tighter budget, because a caller forgives a dropped frame far more readily than a break in the voice: see The avatar.
Properties
Logging
The SDK logs through the standard library, on the logger namedstandin. There is no set_logger function to call: routing it is whatever your deployment already does with logging.
The TypeScript SDK has no stdlib logging tree to hang this on, so it exports a
setLogger function instead. That difference is the only one between the two SDKs here.Limits
- One inbound message is bounded at 2 MB. Audio is about 856 bytes of base64 per frame, and the protocol caps a
video.frameJPEG to fit inside the same envelope. - Outbound audio is bounded by the 1 MB shed threshold above.
- An outcome POST body is bounded at 8 KB.
- A frame type this SDK does not recognise is ignored rather than refused.
start and aclose
await server.start() binds the listener and is transactional: either it is listening when the call returns, or nothing of it survives. A failed bind cleans up its own runner before re-raising. Starting twice raises StandInError rather than quietly binding a second listener.
await server.aclose() stops the reaper, ends every live call with the reason server-shutdown, and then stops listening. It does not wait for a call to finish on its own, which is why a graceful restart is draining first and aclose() after. It awaits the calls’ real teardown tasks, so the loop cannot stop with teardown still pending.
Draining
server.draining = True refuses new calls. Live calls continue. New dials are refused with 503 draining, so a worker that is winding down does not accept calls it will never serve. Set it as the first step of your shutdown, wait for server.active_calls to reach zero, then aclose().
draining when the LiveKit worker itself starts draining, and closes the listener when the worker shuts down.
What the upgrade handler refuses, in order
The order matters, because each check is cheaper than the one after it.
See Security for how the signature and the replay guard work.
Teardown
Teardown runs once per call, in its own task, and it is shielded: a caller being cancelled must never abort it mid-flight, because that is how a slot leaks and acallId starts refusing forever. The order is fixed.
- Cancel the call’s own background tasks and wait for them.
- If
on_startis still on the stack, wait briefly for it to unwind. Dispatchingacloseunderneath a runningon_startwould tear down half-built state, andon_startwould then resume and finish building a provider session nothing will ever close. - Dispatch the handler’s
aclose(reason), before the socket closes, so a handler that wants to speak on the way out still can. - Write the advisory
session.end, then close the socket with a two-second bound. Releasing the slot matters more than a clean close handshake with a peer that has already gone away. - Release the slot, unconditionally, whatever failed above.
Next
Call handler
The five methods this server drives, and the session it hands them.
Security
The handshake, the replay window, and the two signing lanes.