Skip to main content
Config lives under plugins.entries."msteams-bridge".config in your OpenClaw config. OpenClaw looks configuration up by plugin id, so the entry key must be exactly msteams-bridge - under any other key the plugin loads with no configuration at all and every call is refused. The plugin serves two lanes: calling (the media WebSocket StandIn dials) and messages (the managed chat relay). Set secret and you configure both. It must match the connection secret shown in your StandIn dashboard.

Minimal working config (start here)

Everything else on this page is tuning. This is the smallest config that answers a Teams call and talks back, verified end to end against a live call:
Defaults that need no config: port 9442 and path /msteams/calling for the calling lane; messagesPort 9444 and messagesPath /msteams/messages for the managed-bot chat relay. Teams chat on a bring-your-own Azure bot does not come through this plugin: it reaches OpenClaw’s own msteams channel at 127.0.0.1:3978, path /api/messages. So the two paths you publish are /msteams/calling (to 9442) and /api/messages (to 3978) - see Expose your agent for the mount commands.

Realtime provider options

Everything inside realtime.providers.<name> is passed to the voice provider. The full known set - only apiKey is required (plus the three azure* keys on Azure): Spelling matters: from plugin v0.4.1 the known keys are schema-checked, so a typo like azureEndpont fails validation instead of surfacing as a runtime mystery.

You do not need the voice-call plugin

voice-call is OpenClaw’s telephony plugin (Twilio, fromNumber, webhook URLs). This one is separate and self-contained: install msteams-bridge and nothing else. If you are moving from an older setup, note the Teams settings used to live nested inside it as plugins.entries."voice-call".config.msteams. Lift that block up to plugins.entries."msteams-bridge".config and delete the voice-call entry - leaving it behind does no harm, but it configures a phone system you are not using.

Four things that bite on a first install

inboundPolicy defaults to "disabled". A fresh install answers nothing and logs inbound call rejected by policy "disabled". Set allowlist + allowFrom, or "open" for a controlled demo. Note "pairing" behaves exactly like "allowlist" here - it issues no pairing codes for calls.
Azure needs all three azure* keys. With only apiKey set, the plugin dials api.openai.com with an Azure key: the call answers and then drops a second later. See the Azure tab above.
The call answers, then goes quiet when asked to look something up. Two different models are in play and only one of them is configured here - see Two models, two places below.
If you tunnel the calling lane, the /{callId} suffix must survive. StandIn dials wss://<host>/msteams/calling/{callId}. A reverse proxy that mounts a path prefix and strips it delivers / instead, and the upgrade 404s. Mount the path with the backend path repeated, exactly as Expose your agent shows.

Two models, two places

This trips people up because the failure looks like a call bug, not a config one. The agent model is inherited. You do not configure it here, and in a normal install there is nothing to set: the plugin reads whatever your OpenClaw agent already uses.
FailoverError: Unknown model: openai/gpt-5.5, logged mid-call while the voice side works fine.On v0.2.x and earlier, the agent model fell back to OpenClaw’s compiled-in default pair (openai / gpt-5.5) instead of your configured agent. If that model is not registered on your host, every consult fails - but only after the caller has already been greeted, because the voice lane has its own provider block and connects normally. Nothing in your config names gpt-5.5, so there is nothing to grep for.Fixed in v0.3.1: upgrade. To stay on an older version, pin it explicitly with responseModel.
responseModel is an optional override, for when the voice lane should consult a different model than your default agent. Resolution order:
  1. responseModel, if you set it
  2. agents.defaults.model.primary - the normal case
  3. OpenClaw’s built-in default, only if you have configured no agent at all

Vision: the agent sees your screen and camera

Share your screen (or turn your camera on) in the call, and the agent can look at it: ask “what’s on my screen?”, “read this error”, or “what am I holding?”. StandIn’s hosted bridge samples what is shown and delivers frames to your plugin; the agent looks on demand with its look_at_screen tool. Ask about something shown earlier - “what did the previous slide say?” - and the agent reviews its recent scene-change keyframes instead of only the live frame.
When the agent sees. On a published OpenClaw host, frames are delivered with the agent’s next turn - so it sees your screen whenever it reasons, which covers every question you ask it. Truly continuous awareness between turns needs a realtime image channel no published host exposes yet; the plugin uses it automatically if your host gains one. The gateway log says which route it took at the start of each call.
Configuration is minimal, and both switches default sensibly:
Needs plugin v0.4.1+. On earlier versions the image was silently dropped between the plugin and the agent on every published OpenClaw host, so the agent replied “I’m not receiving the shared-screen image” even though everything was configured correctly. If you see that exact sentence, upgrade - it is the version, not your config.v0.4.1 is on the GitHub repo today (install from source or a checkout via plugins.load.paths); the npm release follows once testing completes.

Debugging: “the agent says it can’t see”

The agent’s own wording tells you which layer to look at. Work down this ladder - each answer points at a different cause:
  1. “I can’t see anything yet - make sure your camera or screen-share is on.” No frame has arrived at the plugin. Start (or restart) the share; it can take a few seconds after sharing starts. If it never arrives, confirm the call actually connected through StandIn (you hear the agent) and that your Agent calling URL points at this plugin instance.
  2. “I’m not receiving the shared-screen image.” Frames arrived but were not attached to the agent’s look. That is the pre-0.4.1 bug - upgrade the plugin and restart the gateway.
  3. “I’ve been looking quite a lot in the last minute…” The maxVisionPerMinute budget. Wait a few seconds or raise the cap.
  4. The agent never mentions seeing at all, or refuses. Check realtime.toolPolicy is not "none".
  5. In a MEETING, the agent neither hears nor sees until recording starts. That is requireRecordingStatus: true doing its job: a compliance gate that holds all media until Teams reports recording active. Start recording in the meeting, or set the key to false where your policy allows. 1:1 direct calls are not affected - the gate opens on connect.
  6. Mid-call lookups fail generally (not just vision), e.g. an “Unknown model” error in the gateway log. The agent model, not vision - see Two models, two places.
Two places show you what actually happened, both on your machine:
  • The gateway terminal. A healthy look logs nothing dramatic; failures name themselves (look failed for <callId> - ..., invalid message from <callId>: ...).
  • The agent session log - ~/.openclaw/agents/<agentId>/sessions/*.jsonl. Find the newest file and look for the consult turns: a working look shows the user turn carrying image parts; a broken one shows text only. This one check separates “frames are not arriving” from “frames are arriving but not attached”, which sound identical in the agent’s spoken answer.
Restart the gateway after upgrading. OpenClaw loads plugin code at startup; an upgrade on disk changes nothing until the gateway restarts. If a fix “didn’t work”, check this first.

Voice messages

Someone sends the bot a Teams voice note. By default the agent sees only that an attachment exists - it can name the file back and nothing else, which reads as being ignored. Turn transcription on and the clip is transcribed into the turn, so “listen to this and tell me what you think” is a question the agent can actually answer:
Off by default on purpose. Each clip is a speech-to-text call and a voice note can run for minutes, so this is real per-message cost - something to opt into, not discover on a bill. It also needs the messages lane configured (the secret), since voice notes arrive as chat attachments.
A clip that cannot be fetched or transcribed is not dropped silently: the agent is told it exists and could not be read, so it can say so rather than answering as though nothing was sent.

Mode selection

Set mode to "realtime" or "streaming". If omitted, the runtime auto-selects realtime when a realtime provider resolves, else streaming. Both modes honor the inbound allowlist, outbound call-backs, recording gate, and sessionScope agent memory. See Modes.
In streaming mode, TTS and the agent/model come from your OpenClaw configuration. STT uses a live transcription session - selected by stt.provider / stt.providers if set, else your openclaw-configured transcription provider; if none resolves it falls back to VAD-segmented file transcription. The realtime.* block is ignored except the echo-guard knobs (suppressInputDuringPlayback, echoSuppressionWindowMs, echoBargeInRms), which apply in both modes.

Outbound call-backs (optional, either mode)

This one block enables two things, both of which are silently unavailable without it: Call me back when done. During a call, openclaw_agent_task takes deliverVia. The default, "message", sends the result as a Teams chat message. Set "call" and the agent rings the caller back and speaks it once the work is finished. Chat-to-call. Ask in a Teams chat and get the answer as a phone call, via the call_me_with_the_answer tool. It rings the person it is chatting with - always, and only. There is no target parameter, deliberately: the agent reads untrusted text all day, and a tool that accepted an arbitrary user id would turn any of it into “ring this person”. Requires the messages lane too.
workerBaseUrl is StandIn’s outbound API URL from your dashboard - not a server you host.
Both are offered only when the block is complete. outbound.enabled, workerBaseUrl, tenantId and secret must all be set. With any of them missing the plugin does not expose the capability at all - openclaw_agent_task quietly delivers by message instead, and call_me_with_the_answer refuses and says why. That is deliberate: an agent that promises “I’ll call you back” and then cannot is worse than one that answers in the chat.
Needs v0.4.0+. Earlier versions accepted deliverVia: "call" and acknowledged it to the caller, but never placed a call - the delivery was routed to a tool that does not exist in OpenClaw. Nothing errored; the caller simply waited for a call that was never made.
Your Azure bot needs Calls.Initiate.All, admin-consented, on top of the join/media permissions. Without it every outbound attempt fails at Graph no matter how the plugin is configured.

Key reference

What inboundPolicy: "pairing" actually does today. The plugin issues no pairing codes, expirations, or approval prompts for inbound calls: with "pairing", a caller is accepted only if their AAD object id (or phone number) is already in allowFrom - exactly the same check as "allowlist". A rejected caller is logged with their caller id and the call is closed. The openclaw pairing approve msteams ... flow you may know from the chat channel manages chat DM approvals only; it does not populate this voice allowFrom. Until a real call-pairing flow ships, prefer "allowlist" and manage allowFrom yourself; "pairing" is accepted so such configs keep working with identical (allowlist) behavior.
Treat secret and all provider apiKey values as secrets - keep them out of source control.

Microsoft Graph permissions

You bring your own Teams bot: register an Azure AD app + Azure Bot resource in your tenant, admin-consent the required application permissions, and point its calling webhook at StandIn (the URL is shown in your StandIn dashboard). See the canonical Graph permissions table on the Create the Azure bot page.