Skip to content

Architecture

This page is the contributor-level design of @komaa/openclaw-msteams-bridge. It covers what runs where, how a call moves through the system, and which module owns what. For the wire-level details see the Wire Protocol; for option-by-option settings see the Configuration Reference.

Two processes cooperate at run time. The plugin runs inside your OpenClaw gateway and owns the conversation. The hosted StandIn media bridge joins the Teams call and carries the media. They meet on one HMAC-authenticated WebSocket per call, with the plugin as the server and StandIn as the client.

flowchart LR
    Teams["Teams call"]
    StandIn["StandIn media bridge<br/>(hosted: joins the meeting,<br/>carries the media)"]
    Plugin["@komaa/openclaw-msteams-bridge<br/>(WS server + call brain,<br/>inside the OpenClaw gateway)"]
    Outbound["StandIn outbound API"]
    Model["realtime model<br/>(OpenAI / Azure)"]
    Agent["OpenClaw agent<br/>+ STT/TTS providers"]

    Teams <--> StandIn
    StandIn == "HMAC WebSocket: audio / video / events (JSON), dials in" ==> Plugin
    Plugin -- "REST: place / cancel call<br/>(HMAC-signed, SSRF-guarded)" --> Outbound
    Plugin --> Model
    Plugin --> Agent

Key consequences of this shape:

  • The plugin never handles Teams media itself. It receives PCM audio and JPEG frames on the WebSocket and sends PCM audio and avatar cues back. Everything Teams-specific happens on the StandIn side of the wire.
  • The plugin never dials out for media. Media connections are always inbound from StandIn and always authenticated. The only outbound requests the plugin makes are the HMAC-signed REST calls that place or cancel an outbound Teams call.
  • The agent stays where it is. Tools, memory, and the actual “brain” are your OpenClaw agent; the plugin routes the conversation to it.

The call state machine lives in src/call-lifecycle.ts and every call, inbound or outbound, walks the same states:

flowchart LR
    initiate["initiate<br/>(outbound only)"] --> ringing
    ringing --> answered --> active
    ringing -- "answer timeout" --> noanswer["no-answer / voicemail<br/>(+ cancel ringing)"]
    active -- "session.end / socket close /<br/>reaper / duration cap / policy" --> terminal["terminal<br/>(teardown runs exactly once)"]
    noanswer --> terminal

How the wire events map onto it:

  1. StandIn connects to {path}/{callId} and passes the HMAC handshake.
  2. session.start arrives (within the 10 s pre-start timeout). For inbound calls the caller is checked against the inbound policy; a rejected caller closes the socket with not-allowed. For outbound calls the session is matched to the pending placed call, and a late answer after the answer timeout is declined.
  3. With requireRecordingStatus: true the recording gate holds media processing until Teams reports recording active (recording.status).
  4. Audio and video frames flow; the dialogue pipeline (below) runs; avatar cues go back.
  5. The call ends by exactly one of: session.end, an abrupt socket close, the stale-call reaper (staleCallReaperSeconds), the duration cap (maxDurationSeconds), or a StandIn tier cutoff (which first delivers an assistant.say goodbye). Teardown is idempotent.

Both pipelines consume the same inbound 16 kHz PCM and produce the same outbound 16 kHz PCM; they differ in what happens in between. mode picks one, or the runtime auto-selects realtime when a realtime provider resolves.

flowchart TB
    subgraph RT["Realtime (speech-to-speech)"]
        direction TB
        r1["caller audio 16 kHz PCM"] -- "resample to 24 kHz" --> r2["realtime model (OpenAI / Azure)"]
        r2 -- "model audio 24 kHz + tool calls,<br/>resample to 16 kHz" --> r3["outbound audio.frame 16 kHz<br/>+ speech.marks (visemes) + expression"]
        r4["vision: ambient push of the latest changed frame"]
    end
    subgraph ST["Streaming (STT to agent to TTS)"]
        direction TB
        s1["caller audio 16 kHz PCM"] -- "VAD / segmentation" --> s2["STT provider to text"]
        s2 --> s3["OpenClaw agent to reply text"]
        s3 --> s4["TTS provider to audio"]
        s4 -- "resample to 16 kHz" --> s5["outbound audio.frame 16 kHz<br/>+ speech.marks + expression"]
    end

Realtime is lowest latency with continuous vision, and needs a realtime provider key. Streaming works with any configured STT/TTS, with vision attached per turn.

Shared by both: barge-in (assistant.cancel), verbal interrupts (EN/AR), the echo guard, the group-call gate, DTMF, and bilingual handling. See Realtime and Streaming Modes.

PillarWhat it coversInbound messagesOutbound messages
Perceptionhearing and seeing the callaudio.frame, video.frame, participants, dtmf, recording.status-
Dialoguethe conversation itselfsession.start, session.end, assistant.say, pingaudio.frame, assistant.cancel, pong
Renderingwhat the caller sees-expression, speech.marks, display.image

Contributor-level map of src/ (no internal bridge knowledge required to work on any of these):

AreaModulesResponsibility
Entry + manifestindex.ts, openclaw.plugin.jsonPlugin registration, config schema, fail-closed secret check.
Config resolutionconfig.ts, plugin-config.tsTypes + resolver from raw config to runtime settings and defaults.
Transport + authmsteams-media-stream.ts, protocol.gen.tsThe WebSocket server, HMAC handshake, replay guard, connection caps, message validation.
Runtime + mode selectmsteams-runtime.tsSession routing, inbound policy enforcement, realtime/streaming selection, outbound placement + answer timeout.
Call state machinecall-lifecycle.tsinitiate/ringing/answered/active/terminal, concurrency cap, exactly-once teardown.
Realtimemsteams-realtime.ts, msteams-realtime-tools.tsThe speech-to-speech session, tool exposure and policy.
Streamingmsteams-streaming.ts, msteams-tts*.ts, telephony-*.tsSTT segmentation, agent turns, TTS synthesis.
Visionvision-store.ts, vision-budget.ts, vision-consult.ts, msteams-video-frame.tsFrame ingest, latest-frame + keyframe history, per-call budget, look_at_screen.
Group etiquettegroup-call-gate.tsSpeak-when-addressed, wake phrases, follow-up window.
Avatar cuesexpression.ts, viseme-estimate.tsEmotion inference and viseme timelines for lip-sync.
Minutesmeeting-minutes-docx.tsRecap and .docx minutes with attribution.
Interrupts + echoverbal-interrupt.tsDeterministic stop phrases, echo/barge-in interplay.

The plugin assumes the network between StandIn and itself is hostile and applies these layers:

LayerMechanismWhat it protects against
Handshake authHMAC-SHA256 over {timestampMs}.{callId}, constant-time compareUnauthenticated peers connecting at all
Replay guard60 s timestamp window + single-use (callId, ts, sig) tuplesCaptured handshakes being replayed
Fail-closed secretEmpty or malformed sharedSecret refuses every handshakeAccidentally running open
Inbound policyinboundPolicy + allowFrom; unset denies all callersUnknown callers reaching the agent
Recording gaterequireRecordingStatus holds media until recording is activeProcessing an un-notified conversation
Outbound SSRF guardPlace-call requests pin validated public destinationsThe gateway being steered at internal targets
Resource bounds64 total / 8 per-IP connections, 10 s pre-start, 2 MB inbound frames, 1 MB outbound buffer, concurrency + duration caps, stale reaperResource-exhaustion and wedged calls
  • One wire, additive messages. New message types and fields must degrade gracefully; older and newer peers interoperate. Unknown inbound types are ignored.
  • Fail closed. No secret, no service; policy unset, no callers.
  • Exactly-once teardown. Every exit path funnels through the same teardown; a second trigger is a no-op.
  • The bridge is a black box. The plugin documents and depends only on the wire contract, never on how the StandIn media bridge is built.