Skip to main content
Joining a Microsoft Teams call means speaking Microsoft’s own real-time media protocol. You do not have to. It is StandIn, a hosted service at standin.komaa.com. You run a worker, and the SDK is the other end of the socket StandIn dials.

Three responsibilities, three owners

That split is why a plugin is small. The echo plugin answers a real Microsoft Teams call in under a hundred lines, and it is not a toy: it is the same shape every plugin keeps.

The seam

A handler implements as many of seven callbacks as it needs. All of them are optional, a missing one is a no-op, and nothing inherits from anything. The names are snake_case in Python and camelCase in TypeScript and are otherwise identical. An exception raised from any of them is logged and ends that call alone. One bad call must never take the worker with it.
Where the two languages put the last two. TypeScript declares all seven on CallHandler, every one optional. Python declares five on CallHandler and keeps on_video_frame and on_speaker_change on two separate protocols, VideoHandler and SpeakerHandler. The reason is mechanical: Python’s CallHandler is runtime_checkable, and a runtime check demands every member the protocol declares, so a sixth method there would make isinstance(handler, CallHandler) fail for every handler written before the vision lane existed. The server duck-types both, through the same guarded dispatch as the other five, so implementing the method is enough and inheriting from anything is never required.

What the call gives back

CallSession is the handler’s only way to reach the socket. The handler seam is narrow; the session is wide, and the same seventeen members are on it in both languages. What you read. What you act with.
The SDK sends the hints and receives the frames. StandIn draws the tile. express, send_speech_marks, display_image and send_tile_frame go out; latest_video_frame and on_video_frame come in. A missing hint is ignored, not a failed call, and none of it changes a sample of what the caller hears.
Gate storage on recording_active / recordingActive, not on a sentence you remember seeing. Anything that stores or ships what the caller said or showed to a third party belongs behind this flag. The server starts it from the session.start snapshot and then follows every later recording status change, and a reported change wins over the snapshot whichever arrives first. An omitted snapshot field means the state was unknown at answer time, not “not recording”: letting the snapshot overwrite a status that landed first would silently shut every recording-gated capability for the whole call. A recorded call is one the caller was told is being kept. An unrecorded one is not.
cancel_playback() is the only lever that un-sends audio already handed to the service. Call it the moment your provider reports the caller started speaking, and call it before you cancel the response upstream. Without it a barge-in stops the model but the bot keeps talking for the length of the buffered PCM, which is the single most common “it ignores me when I interrupt” report.
end() is safe to call from inside on_start, which is how a plugin refuses a call it does not want. It returns immediately there, and the close runs once on_start unwinds.

Audio, and why the helpers are in the SDK

The wire is PCM16, 16 kHz, mono, little-endian, in both directions. Almost nothing else is. The realtime speech-to-speech models speak 24 kHz, most TTS vendors emit 22.05 or 24 kHz, and none of them chunk on the wire’s frame boundary. So every plugin that is not a pure passthrough ends up writing the same two things: a resampler, because the rates differ, and a frame aligner, because a resampled buffer does not divide evenly into the wire’s 640-byte frame, and dropping the remainder clips the end of every turn. Both live in the SDK, in both languages, because they are properties of the wire, not of any framework. They are dependency-free on purpose: speech at these rates does not need a windowed-sinc filter, and the alternative is putting numpy or scipy on the critical path of every audio frame of every call. Helpers: resample_pcm16 / resamplePcm16, frame_duration_ms / frameDurationMs, and FrameAligner with push, flush, reset and pending.

Two lanes

Calls and chat are separate sockets with opposite directions, and that difference decides what you have to expose. Because the chat lane is dialed out, Microsoft Teams chat needs no listener, no open port and no tunnel, and your agent never holds a Bot Framework credential. StandIn owns the Microsoft Teams bot, authenticates the activity, resolves it to your connection, and strips the bot @mention before your handler sees the text. Both lanes authenticate with your connection secret. One key covers both, and that is the usual case. The chat lane reads STANDIN_CHAT_SECRET first and falls back to STANDIN_SECRET, so a deployment that issued a separate key for chat sets the extra variable and changes nothing else. ChatChannel, InboundMessage, parse_inbound / parseInbound, build_reply / buildReply, PersonalChats and SCHEMA_VERSION are in both SDKs. Two names differ, and each one is a first-line failure if you assume otherwise:
  • The personal-or-group branch. Python reads it off the message as the msg.is_personal property. TypeScript exports isPersonal(msg) as a free function.
  • DEFAULT_CHAT_URL is at the TypeScript barrel and not the Python one. Python keeps it in the module, so it is from standin.chat import DEFAULT_CHAT_URL, not from standin import .... Neither is usually needed: ChatChannel already falls back to it, and STANDIN_CHAT_URL overrides it.
build_reply / buildReply echoes tenantId and conversationId from the inbound message exactly. StandIn rejects a mismatch, and that check is the cross-tenant leak guard the whole relay rests on. It echoes bindingId too when the inbound message carries one, for the same reason one level down: a single tenant can hold several connections, so the tenant alone no longer says which one a reply is from. Do not construct a reply payload by hand.

Authentication

There are three signing helpers, not two. They sign different things, they carry different freshness windows, and none is a substitute for another. The names below are the Python ones; TypeScript has the same three in camelCase. Headers are X-StandIn-Timestamp, X-StandIn-Signature and X-StandIn-Signature-V2. Verification fails closed on empty input, and each handshake is single-use, so a replayed upgrade is rejected even inside its window.
Sign the exact bytes you will transmit. Parsing JSON and re-serializing it can change whitespace, key order or Unicode escaping, and any one of those turns a valid signature into a rejection nobody can explain from the payload alone.
The secret in your worker must byte-match the one registered in StandIn, or the handshake fails and no call connects. It fails silently by design: an endpoint that explains why it rejected you is an endpoint that helps an attacker, so budget for reading the value rather than for reading an error message. The same is true one lane over: a chat channel signing with the wrong key gets no explanation either, which is why STANDIN_CHAT_SECRET is worth checking whenever chat is the half that is quiet.

Capacity and the watchdogs

CallServer refuses work rather than degrading under it. There are 64 slots by default (max_connections / maxConnections), and a dial that arrives while the server is draining, or when every slot is taken, gets 503. Both checks run before any signature is verified, so a flood cannot make the worker spend CPU on crypto for calls it was never going to accept, and a worker that is winding down stops taking calls it will never finish. Five bounds then sit on a call that nobody closes, four of them on by default:
The reaper is the one none of the other three catch. An agent dispatch that never lands leaves session.start delivered, on_start returned, and the caller still talking, so the pre-start, the on_start timeout and the audio-idle watchdog are each satisfied while the caller sits on a live call hearing nothing. Answered means audio went out: send_audio marks it for you, so every plugin is covered without writing anything. mark_answered() exists for the agent that joins and listens first, and a plugin that joins a room should call it when the agent’s own audio track appears, not when a participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering.
The ceiling is the polite one. Set max_call_seconds and the server flushes playback, speaks goodbye_text through the handler’s own on_goodbye, allows goodbye_grace (6 s by default) for it to finish, and then closes. The goodbye goes through the callback StandIn’s own closing line already uses, so no plugin needs new code to honour it. Teardown is idempotent and shielded, and always frees the slot.

Microsoft Graph permissions

Bringing your own Microsoft Teams bot means registering an Entra app and an Azure Bot resource in your tenant, admin-consenting the application permissions, and pointing its calling webhook at StandIn. See the canonical Graph permissions table.

The protocol is generated

protocol/schema.yaml in the repository is the single source of truth for the wire. Both SDKs’ protocol modules are generated from it and gated against drift, and shared conformance vectors assert that Python and TypeScript produce byte-identical payloads. Neither module is hand-edited, which is why the two languages cannot quietly diverge.