Skip to main content
@komaa/standin-sdk/openclaw connects the realtime voice provider configured in OpenClaw to Microsoft Teams calls through StandIn.
Scope. This plugin covers the call path and the voice: a caller converses with the realtime provider OpenClaw is configured with, with barge-in, an echo guard and a recording gate. OpenClaw’s agent consultation, tools, skills and memory are reached from chat today, not from the realtime session this plugin drives. Scope has the whole boundary in one list.

This is a plugin, not a worker you run

There is no node app.js here, and there is not supposed to be. The plugin loads inside the OpenClaw gateway process, declared by its openclaw.plugin.json, because it consumes the host’s realtime speech-to-speech session, its provider registry and its logger, all of which are in-process objects. OpenClaw’s external HTTP surface is text-only, so a standalone worker could only use OpenClaw as a text brain and bring its own STT and TTS, which is exactly the part this plugin exists to avoid. The plugin registers one host-managed background service, so OpenClaw’s own lifecycle starts the call listener at boot and tears it down on shutdown or reload. There is nothing to supervise separately.

Install

OpenClaw 2026.6.10 or newer. Install the SDK where your gateway can reach it:
openclaw is an optional peer dependency, never a bundled one: the plugin runs inside the gateway and consumes the host’s own realtime session, provider registry and logger. OpenClaw does not load a plugin by package specifier. It is handed a directory through plugins.load.paths and discovers what is inside it, so point it at the one inside the package:

Configure and restart

Merge this into ~/.openclaw/openclaw.json, keeping the plugins.load.paths entry above:
Then restart the OpenClaw gateway, so the plugin’s background service starts the call listener:
Now expose port 9442 at the /msteams/calling path, and register the public wss:// URL as your StandIn identity’s agent voice URL. Expose your agent carries the mount command and the probes, and it is the only page that does: three spellings of one URL across three pages is what caused a live incident here. Call your StandIn number.

Configuration reference

The plugin fails closed on the secret. A non-string value coerces to empty rather than to "[object Object]", so an unresolved secret reference stops the listener starting instead of arming it with a guessable value.

Who may call

inboundPolicy takes disabled, allowlist, pairing or open, and an unset or unknown policy refuses. Defaulting the other way would mean a config typo silently opens your agent to anyone who can reach the number. allowFrom takes the caller’s AAD object id, not their email. It matches on exact id, case-insensitive, or on digits-only for a phone number. The id match is what lets a Microsoft Teams caller be allowlisted at all: their id is an AAD object id, and phone normalization would reduce it to the empty string and match nothing. An AAD object id is empty for guest and anonymous callers, so an anonymous caller can never be allowlisted. That is the intended behaviour.
pairing is enforced as a plain allowlist on calls: there are no pairing codes, expirations or approval prompts on this path, so the fix for a refused caller is the same as under allowlist.

How a call flows

StandIn dials wss://<your-host>/msteams/calling/{callId} once per call. The SDK answers that dial and speaks the wire protocol; the plugin resamples both legs, guards against echo, and handles barge-in. It carries the three things that make a call feel right rather than merely work:
cancelPlayback() runs before the response is cancelled upstream, so the caller stops hearing the turn they interrupted rather than listening to the rest of it play out.There are two triggers. The model truncating its own turn because it heard the caller, and a deterministic verbal interrupt matched in code: “stop”, “hold on”, “never mind”. The second exists because the model is mid-generation when one arrives, and matching the phrase ourselves is what makes the cut feel instant. It only fires while the agent is actually still speaking, because otherwise “stop” is just a word in a sentence.
On a speakerphone the agent’s own voice comes back up the caller leg loudly enough for a realtime model’s VAD to answer itself, which produces a call where the agent greets, interrupts itself and re-greets while the caller has said nothing.Caller input is dropped while our own audio is still playing out, plus a short window after it, unless it is loud enough to be a real interruption. Tune with echoSuppressionWindowMs and echoBargeInRms, or turn it off entirely with suppressInputDuringPlayback: false.
With requireRecordingStatus on, no caller media reaches the model until Microsoft Teams reports recording active. This is a Microsoft Media Access API obligation rather than a preference: a bot that processes call media before updateRecordingStatus goes active is out of policy.It is off by default because the hosted service can be configured either way, and a gate nobody asked for is a call of silence.

Refusal reasons

The plugin refuses a call in four places, which map to three reason strings. Each one is handed to StandIn rather than dropping the socket, so the refusal is visible on the call instead of appearing as silence. Policy and capacity are checked before the call slot is taken, so a refused caller cannot consume capacity by dialling repeatedly.

Checking it works

The gateway logs one line at boot when the listener is up:
If instead it says nothing started, the secret is missing or unresolved. If it warns that no realtime voice provider resolved, every call will be refused with realtime-unavailable, so set the provider’s API key. That warning names the configured provider id when there is one, which separates “the key is missing” from “no provider is configured at all”.
The provider is resolved once, when the service starts, not per call. Adding or fixing an API key therefore needs the gateway restarted or the plugin reloaded before the next call sees it. The boot line above is the confirmation that it did.

Scope

What this plugin covers, stated once so you can design around it:
  • The call and the voice. A realtime speech-to-speech session, barge-in, the echo guard, the recording gate and the inbound policy. That is the whole of what a caller talks to.
  • OpenClaw agent consultation, tools, skills and memory are reached from chat, not from the realtime session this plugin drives. The prompt the caller talks to is realtime.instructions, which is this plugin’s own configuration rather than the agent’s.
  • Chat and messages belong to the SDK’s own chat lane, which is a separate surface with its own page.
  • Vision, avatar expression, visemes and meeting minutes are on the StandIn call wire and in the SDK core, and this plugin does not wire any of them into the OpenClaw realtime session. That is a boundary of this plugin, not of the call. Reach for VisionTools and CallTools directly from a handler of your own if you need them: see Vision and the avatar, The avatar and Meeting recap.
  • Providers are the realtime speech-to-speech ones OpenClaw resolves, and the plugin consumes the host’s own registry rather than carrying a second list. A provider that offers only speech to text and text to speech is not one this plugin can drive: the whole design rests on the model hearing the caller directly, which is where the latency goes.