The bot joins but never speaks (HMAC mismatch)
Symptom: the bot joins the call (or the bridge connects), then nothing: no greeting, no response, no error in your plugin logs. Thesecret - one key on both OpenClaw and Hermes - must byte-match the secret set in your
StandIn dashboard. A mismatch fails the HMAC handshake silently - the bridge simply drops the
connection, so both sides look “up” while no audio flows. This is the #1 first-call issue.
Fix:
- Re-paste the secret on both sides. Watch for a trailing newline or whitespace picked up when
copying, and for shell quoting that swallows characters like
$or!. - If the secret lives in
.env, confirm the variable actually resolves (Hermes:hermes msteams-bridge status). - Rotate the secret in the StandIn dashboard and set the new value in the plugin config. A rotated
secret is not picked up hot yet (hot-reload is being worked on): restart your plugin after
changing it -
openclaw gateway runagain (OpenClaw); on Hermes,hermes msteams-bridge serveagain (standalone) orhermes gateway restart(gateway-managed - never startservenext to a gateway-managed setup, the two would fight over the port). On the StandIn side the rotated secret is picked up automatically: the caller hot-reloads it within about a minute, so no pod restart or support request is needed.
Bot does not join: “agent is not reachable”
Symptom: the call never gets a bot, and the sandbox console shows “Bot could not join… agent is not reachable”. StandIn connects to your plugin from the internet. If it cannot reach yourwss:// endpoint,
the bot has nothing to bridge to and does not join.
Fix, in order:
- Is the plugin running? Start it -
openclaw gateway run(OpenClaw); on Hermes,hermes msteams-bridge serve --handler realtime(standalone) orhermes gateway run(gateway-managed, withplatforms.msteams_bridge.enabled: true) - and confirm it is listening on its port. Run one Hermes mode or the other, never both. - Is the tunnel up? If you expose the endpoint via a tunnel (e.g. Tailscale), a dropped tunnel is the most common cause. Restart it and test the public URL from outside your network.
- Is the bind reachable? A loopback-only bind (
127.0.0.1) is invisible from the internet unless a tunnel forwards to it - bind0.0.0.0or keep the tunnel in front. - Does the registered URL match? The URL in your StandIn dashboard must point at the live endpoint, including port and path (see below).
Voice works, chat is silent, and nothing appears in any log
Symptom: calls connect and your agent talks, but a Teams chat message produces nothing: no reply, no error, and no line in any log - not the plugin, not the gateway, not your agent. Every component looks healthy, because every component is. The empty log is the diagnosis, not the mystery. Voice and chat are two separate endpoints. Chat arrives as an ordinaryPOST to your public /api/messages; if your tunnel has no rule for that path,
the tunnel answers 404 at its own edge and the request never reaches a process that writes logs.
Voice keeps working the whole time because it rides a different rule. So there is nothing to grep for:
you have to look at the tunnel, not at the agent.
If a log does show the message arriving (OpenClaw logs
received message), the mount is fine and
this is DM gating instead - see
The bot answers calls but ignores Teams chat.1. Read what your tunnel actually publishes
- No
/api/messagesline at all. Chat was never published. This is the bug, and it is why nothing is logged. - A
/line such as|-- / proxy http://127.0.0.1:9442. That is a whole-port mount: it publishes every route on that one port, so the chat lane, which listens on a different port, is still unreachable. It also spends one of Funnel’s three slots (443, 8443, 10000). - A target that drops the path, e.g.
proxy http://127.0.0.1:3978with no/api/messages. The request arrives stripped and your plugin 404s it. The path must be repeated on both sides.
2. Check the target for your runtime
The public path and the local voice port are the same on every runtime:/msteams/calling in front
of 9442. Chat is 127.0.0.1:3978 /api/messages on both OpenClaw and Hermes, because chat is
a listener separate from the voice bridge (9442). That separation is the whole reason a perfectly
healthy voice port tells you nothing about chat. Full table:
Wrong port or path.
3. Publish the missing lane
Mount the path exactly as Expose your agent shows - one rule per path, with the path repeated in the proxy target - then re-runtailscale funnel status and confirm both lines
are there before testing again.
4. Prove each lane, from outside and from inside
Chat is a plainPOST, so no special flags are needed:
--http1.1 is not optional through a funnel (see the next
section):
/probe is a stand-in call id, and it matters: only OpenClaw answers the bare
/msteams/calling. Hermes, LiveKit and ElevenLabs register the route as /msteams/calling/{callId},
so a bare path returns 404 on a perfectly healthy bridge. A path mount forwards everything under
/msteams/calling, so the segment reaches the bridge either way.
Run the same two probes against the local port to tell “the tunnel is wrong” from “nothing is
listening”:
Connection refused is always a failure. A 404 is only a failure once you have ruled out the two
probes that fake one: a missing --http1.1 (see the next section) and a bare voice path. A 401
means the lane is reachable and the next thing to check is the shared secret or the DM allowlist, not
the tunnel.
A voice endpoint probes as 404 through a funnel
Symptom:tailscale funnel status shows the /msteams/calling rule, the plugin is running, and a
curl probe with WebSocket headers still returns 404, which looks exactly like a missing mount. You
go hunting for a routing bug that does not exist. This is an HTTP/2 false negative, and it is the
probe that is wrong, not the endpoint.
Tailscale Funnel serves HTTP/2 to the client, and HTTP/2 has no connection-level upgrade. Over h2
your Connection: Upgrade and Upgrade: websocket headers are not a handshake, they are just headers:
curl sends an ordinary GET, the bridge sees a plain GET on a WebSocket-only route, and answers the
only thing it can, 404. Nothing is broken. The probe was never a handshake.
Force HTTP/1.1 and the same URL answers properly:
The bot answers calls but ignores Teams chat (DM pairing)
Symptom: voice works end to end, chat messages reach your agent (the gateway log showsreceived message), but the bot never replies.
Both runtimes gate direct messages from senders they do not know. StandIn delivered the message;
your agent declined to act on it. What you see differs per runtime:
OpenClaw: closed by default, and silent. With dmPolicy: pairing (the default), DMs from
senders you have not approved are dropped with dropping dm (not allowlisted), and a pairing
request is queued silently - the sender gets nothing back.
Fix (OpenClaw): list the pending requests:
channels.msteams.dmPolicy: "open" in
openclaw.json to accept DMs from anyone (less safe; pairing is recommended).
Hermes: gated too, but visible by default. Teams chat runs through the Hermes gateway’s Teams
platform (hermes gateway run), not the msteams_bridge plugin. With no allowlist configured, an
unknown sender is not ignored: the bot replies with a pairing code (“I don’t recognize you
yet!”) instead of answering. Approve that code once:
TEAMS_ALLOWED_USERS (or the
global GATEWAY_ALLOWED_USERS) set, DMs from senders not on the list are dropped with an
Unauthorized user: ... on teams warning in the gateway log and no pairing code is offered -
add the sender’s AAD object ID (or UPN) to TEAMS_ALLOWED_USERS and restart the gateway
(hermes gateway restart). TEAMS_ALLOW_ALL_USERS=true accepts DMs from anyone (dev only).
Hermes rate-limits pairing replies to one per sender per 10 minutes, so a rapid second test
message getting no answer is expected - wait, or approve the code you already received.
Community tier: bot leaves or will not join (daily budget)
The community tier is metered: 5 minutes of bot time per day, per room, and up to 5 new rooms per day per account. When a room’s budget runs out, the bot leaves and returns the next day; once the room cap is hit, new rooms fail until tomorrow. Reusing an existing room does not count toward the room cap. The cutoff is abrupt: when the 5 minutes run out the bot leaves immediately, even mid-sentence, with no warning announcement. A bot that “hung up on me out of nowhere” at around the 5-minute mark is the daily budget, not a failure. The same 5-minute daily budget applies to a free identity (your own bot on the free plan), per identity. “The next day” flips at midnight UTC, not your local midnight - a bot that works again “early in the morning” or “only after lunch” is the UTC reset, not flakiness. If you need more time or inbound calls to your own identity, use the standard setup in the Quickstart with your own Teams bot.Community tier: the bot cannot join a meeting in your own tenant
Symptom: instead of using the generated link, you try to get the community bot into one of your own Teams meetings (pasting your meeting’s link, or inviting the shared bot from inside your organization’s meeting) - and no bot ever joins, with no visible error. This bites second tenants especially: a teammate in another organization tries the sandbox against their own meeting and it looks simply broken. This is a Microsoft Graph restriction, not a StandIn bug: a bot can only join meetings hosted in its own tenant. Cross-tenant joins are rejected by Graph with error 7505 (“authorization tenant mismatch”) - the bot app, its token, the meeting, and the organizer must all live in the same tenant. The shared community bot lives in StandIn’s tenant, so it can never join a meeting created in yours. Fix: use the flow the community tier is built around. Click Generate meeting link in your sandbox room and share that link: the meeting is created in StandIn’s tenant (where the bot is allowed to operate), and anyone - from any tenant, or with no Microsoft account at all - joins it as a regular guest. If you need a bot that answers calls or joins meetings in your own tenant, that is the standard setup with your own Teams bot: see the Quickstart.Wrong port or path (and callId confusion)
Every runtime listens on the same default port and the same path:
All StandIn plugins share this port layout: calling
9442, managed chat 9444, Bot Framework 3978.
The LiveKit and ElevenLabs bridges have no chat lane at all - they serve the calling WebSocket,
/healthz and /metrics, and nothing else. Leave the Agent messages URL empty for those two;
a messages URL you register there points at nothing.
Both of those bridges take their port from PORT, and 9442 is only the built-in default: the Node
.env.example in each repo sets PORT=9442 outright while the Python one ships it commented, so
either way a local .env can move it without you noticing. Read the value you actually run (the bridge logs its listen address
at startup) rather than assuming. Both also default BIND to 0.0.0.0, so they answer on every
interface, not only loopback - set BIND=127.0.0.1 if only the tunnel should reach them.
Their paths differ too, which matters when you write the mount: LiveKit anchors on WS_PATH (default
/msteams/calling) and rejects any other path, while ElevenLabs takes the last path segment as the
call id and therefore accepts whatever path you registered. See
Expose your agent for the mount commands.
Registering a URL with a stale or moved port (an old pre-9442 default, or a bridge you moved with
PORT) is a frequent mix-up: the bridge connects to a closed port and the call never reaches your
agent. Double-check the full URL you
registered in the StandIn dashboard, e.g. wss://<your-public-host>/msteams/calling for a
path-mapped funnel, or wss://<your-public-host>:8443/msteams/calling when you published a whole
port. Always the public wss:// URL, never the local ws:// bind.
Before concluding a path is wrong, probe it properly: a voice endpoint needs a real WebSocket
upgrade and
--http1.1 through a funnel, or a healthy bridge answers 404 and sends you
hunting for nothing. See
A voice endpoint probes as 404 through a funnel
for the per-runtime probe paths and what “alive” looks like.Each call carries its own
callId, and the HMAC handshake signs it per call - you never configure
a callId yourself. If logs show signature failures for a specific callId, that is the shared
secret (see above), not the id.clawhub: install does not resolve
Check the package name against the registry you asked for. The two are deliberately different: npm
publishes @komaa/openclaw-msteams-bridge, ClawHub lists @komaa/msteams-bridge. A clawhub:
install of the npm name resolves to nothing, and so does an npm: install of the ClawHub name.
From npm:
npm install - that only downloads the package into the current folder without registering
it as an OpenClaw plugin.) The npm package is
@komaa/openclaw-msteams-bridge;
see the plugin README for the full steps.
Both paths install the same plugin, which loads under the id
msteams-bridge either way - so your
plugins.entries."msteams-bridge" config is identical whichever registry you install from.Contact support
If none of the above resolves it, write to us and include thecallId from your plugin logs, the
backend you are running (OpenClaw, Hermes, LiveKit or ElevenLabs), and
roughly when the call happened. We do not keep transcripts, so that context is what we work from.
Support: support [at] komaa.comStandIn is operated by Komaa DigiTech. For privacy requests and data-subject rights, see the
Privacy Statement.