Skip to main content
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-callId rule
  • the frame loop: inbound session.start, audio.frame, video.frame, ping, participants, dtmf, recording.status, assistant.say and session.end, and outbound audio.frame, assistant.cancel, expression, speech.marks, display.image, display.frame, pong and session.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_start timeout, 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
A frame type the SDK does not recognise is ignored by contract, so an older plugin and a newer StandIn interoperate. That rule is about unknown frames. The avatar and display messages are not among them: they are messages this server builds and sends, and 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

All arguments are keyword-only. Every timer is in seconds; the TypeScript twin spells the same options in milliseconds.

handler_factory

Builds one handler per call, called with no arguments. This is the plugin. A non-callable raises StandInError 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 to STANDIN_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 gets 503 at capacity.

pre_start_timeout

Seconds a socket may stay silent after authenticating before it is dropped for never sending session.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’s on_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.
Call mark_answered() when the agent’s track appears, not when a participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering.

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.
The flush has to come first, or the goodbye queues behind however many seconds of agent audio the service still holds and the call ends before anyone hears it.

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.
Passing it is what creates the route: 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-V2 over 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 is 401, and a good one is 204. 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.
Your OutboundLane reads this to sweep an unanswered call. See Reaching people.

Backpressure

Outbound audio is shed, not queued. When the transport has more than MAX_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.
Two decisions in that sentence are load-bearing: The timeline advances anyway. It is the caller’s clock. A dropped frame is a gap in what they hear, not a rewind, and stalling the clock would make every later frame claim a time that has already passed. Audio only. Control frames are never shed, because control frames are what end a call, and a call that cannot be ended is the failure this exists to prevent. A slow or wedged peer otherwise turns “await every send” into an unbounded queue, and those awaits are what stall the provider receive loop feeding them. Shedding keeps the loop moving: the caller hears a gap rather than the call wedging. Watch 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 named standin. 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.
Every call id that reaches a log line is rendered log-safe first: control characters are replaced and the length is bounded, so an id chosen by somebody else cannot forge a log line of its own.

Limits

  • One inbound message is bounded at 2 MB. Audio is about 856 bytes of base64 per frame, and the protocol caps a video.frame JPEG 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().
The LiveKit plugin wires this up for you: it sets 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 a callId starts refusing forever. The order is fixed.
  1. Cancel the call’s own background tasks and wait for them.
  2. If on_start is still on the stack, wait briefly for it to unwind. Dispatching aclose underneath a running on_start would tear down half-built state, and on_start would then resume and finish building a provider session nothing will ever close.
  3. Dispatch the handler’s aclose(reason), before the socket closes, so a handler that wants to speak on the way out still can.
  4. 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.
  5. Release the slot, unconditionally, whatever failed above.
The first close reason wins. A cascade of close causes must not overwrite the one that actually ended the call. Call handler lists the reasons a handler will see.

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.