Skip to content

Wire Protocol

This is the contract the StandIn media bridge speaks with the plugin over the WebSocket. It’s useful if you’re debugging a connection, writing tests, or extending a handler. Everything here matches protocol.py, hmac_auth.py, and bridge_server.py.

The design goal is forward compatibility: messages are camelCase JSON, additive, and tolerant - unknown fields are ignored and unknown message types degrade gracefully, so older and newer peers interoperate.

For each Teams call, StandIn opens one WebSocket to:

ws://<host>:<port>/voice/msteams/stream/{callId}

{callId} in the URL is authenticated by the HMAC headers and later cross-checked against the callId in the session.start body - a mismatch closes the call.

On the upgrade request, StandIn sends:

HeaderValue
X-StandIn-Timestampthe signing timestamp, in milliseconds
X-StandIn-Signaturethe signature (lowercase hex)

The signature is:

HMAC-SHA256(shared_secret, "{timestampMs}.{callId}") # lowercase hex

Example:

X-StandIn-Timestamp: 1720598400000
X-StandIn-Signature: 9f8c... (hex digest of "1720598400000.abc123")

Verification order (verify_upgrade): header presence → timestamp parse + window → constant-time signature compare → single-use replay check. On success the WebSocket is accepted; any failure returns HTTP 401.

  • Window: the timestamp must be within ±60 s of now (hmac_window_ms).
  • Replay guard: each accepted (callId, timestamp, signature) tuple is single-use; a captured handshake can’t be replayed. Entries expire at the timestamp’s own horizon (ts + window).

After auth: a global max_connections (64) and per-IP max_connections_per_ip (8) cap return 503 when exceeded; a duplicate live callId closes the new socket (POLICY_VIOLATION) rather than clobbering the running call; and a connection that doesn’t send session.start within pre_start_timeout_s (10 s) is reaped.

The same server also answers a plain HTTP GET /health with ok - a liveness probe, not part of the call protocol.

All audio on the wire is PCM, 16 kHz, 16-bit signed, mono, little-endian, carried as 20 ms / 640-byte frames, base64-encoded in the payloadBase64 field. (The realtime model internally uses 24 kHz; the plugin resamples on both sides.)

All frames are JSON text with a type discriminator. Fields below are camelCase.

Opens the call. callId and threadId are required.

{
"type": "session.start",
"callId": "abc123",
"threadId": "19:meeting_xyz@thread.v2",
"caller": { "aadId": "00000000-...", "displayName": "Ada Lovelace", "tenantId": "..." },
"recordingStatus": "active",
"direction": "inbound"
}
FieldTypeNotes
callIdstring (required)Must match the URL callId.
threadIdstring (required)Teams chat/thread id.
callerobjectaadId, displayName, tenantId - all best-effort/nullable (blank → null).
recordingStatusstringactive | inactive | unknown.
directionstringinbound | outbound (outbound = a call-back delivery leg).
{ "type": "session.end", "reason": "hangup" }

reason is a free-form string.

{ "type": "recording.status", "status": "active" }

status (required): active | inactive | unknown. Media processing is gated until active unless require_recording_status is off.

{ "type": "audio.frame", "seq": 42, "timestampMs": 840, "payloadBase64": "", "speakerName": "Ada" }
FieldTypeNotes
seqintFrame sequence number.
timestampMsintPlayout timestamp in ms.
payloadBase64string (required)PCM 16 kHz / 20 ms / 640 bytes, base64.
speakerNamestringOptional - unmixed-audio speaker attribution for the minutes.
{ "type": "video.frame", "source": "screenshare", "ts": 1234, "width": 1280,
"height": 720, "mime": "image/jpeg", "dataBase64": "",
"participantId": "", "participantName": "Ada" }
FieldTypeNotes
sourcestring (required)camera | screenshare.
tsintFrame timestamp (a new ts = a new scene).
width, heightintPixel dimensions.
mimestringDefaults to image/jpeg.
dataBase64string (required)The encoded image.
participantId, participantNamestringOptional attribution.
{ "type": "participants", "count": 3 }

Drives the group-call gate (2+ humans = meeting mode).

{ "type": "dtmf", "digit": "1" }

digit (required): 0-9, *, or #.

{ "type": "ping", "ts": 1720598400000 }

The plugin replies with a pong echoing ts.

{ "type": "assistant.say", "text": "We're at time - thanks for calling, goodbye!" }

text (required). StandIn asks the agent to speak this line - e.g. a brief goodbye right before a limit cutoff. Not recording-gated (StandIn explicitly requested it).

TTS / realtime audio back to the caller. Same shape as inbound audio.frame (seq, timestampMs, payloadBase64).

{ "type": "assistant.cancel", "turnId": 7 }

Barge-in - flush playback for turnId.

{ "type": "expression", "emotion": "happy" }

Avatar emotion cue: neutral | happy | sad | surprised | thinking. Cosmetic/best-effort.

{ "type": "speech.marks", "ts": 840, "marks": [ { "tMs": 0, "visemeId": 12 }, { "tMs": 60, "visemeId": 3 } ] }

Viseme timeline for lip-sync (marks = [{tMs, visemeId}]).

{ "type": "display.image", "dataBase64": "", "mime": "image/png", "ts": 0,
"durationMs": 5000, "mode": "fullscreen", "caption": "Here's the chart" }

show_to_caller - render an image on the bot’s tile. durationMs, mode, and caption are optional.

{ "type": "pong", "ts": 1720598400000 }

Keepalive reply echoing the inbound ping timestamp.

The server routes each inbound frame to a CallSessionHandler.on_* callback:

Inbound messageHandler callback
session.starton_session_start
audio.frameon_audio_frame
video.frameon_video_frame
recording.statuson_recording_status
participantson_participants
dtmfon_dtmf
assistant.sayon_assistant_say
session.end (or abrupt close)on_session_end
ping(answered by the server with pong)

Handlers drive audio and avatar cues back via CallSession.send_* helpers, which serialize the outbound builders above.