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 aresnake_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.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_personalproperty. TypeScript exportsisPersonal(msg)as a free function. DEFAULT_CHAT_URLis at the TypeScript barrel and not the Python one. Python keeps it in the module, so it isfrom standin.chat import DEFAULT_CHAT_URL, notfrom standin import .... Neither is usually needed:ChatChannelalready falls back to it, andSTANDIN_CHAT_URLoverrides it.
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 incamelCase.
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.
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.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.