Skip to main content
The issues below cover almost every failed first call. Work top to bottom: secret, reachability, the chat mount, limits, address.

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. The secret - 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 run again (OpenClaw); on Hermes, hermes msteams-bridge serve again (standalone) or hermes gateway restart (gateway-managed - never start serve next 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 your wss:// endpoint, the bot has nothing to bridge to and does not join. Fix, in order:
  1. Is the plugin running? Start it - openclaw gateway run (OpenClaw); on Hermes, hermes msteams-bridge serve --handler realtime (standalone) or hermes gateway run (gateway-managed, with platforms.msteams_bridge.enabled: true) - and confirm it is listening on its port. Run one Hermes mode or the other, never both.
  2. 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.
  3. Is the bind reachable? A loopback-only bind (127.0.0.1) is invisible from the internet unless a tunnel forwards to it - bind 0.0.0.0 or keep the tunnel in front.
  4. 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 ordinary POST 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

A working two-lane setup prints one line per public path, each pointing at the local port that serves it:
Read it as a routing table:
  • No /api/messages line 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:3978 with 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-run tailscale funnel status and confirm both lines are there before testing again.
Installed before the chat lane was handled? An older installer run mounted voice only on Hermes: no /api/messages rule, an empty chat URL at pairing, no chat step printed, and the run still reported success. That is exactly the silent failure above. A current run provisions both lanes on Tailscale and prints the commands to run yourself on ngrok, cloudflared or devtunnel, so re-running the installer is the quickest fix. Otherwise add the mount by hand as in step 3, then set the chat URL on your connection in the dashboard.

4. Prove each lane, from outside and from inside

Chat is a plain POST, so no special flags are needed:
Voice is a WebSocket upgrade, and --http1.1 is not optional through a funnel (see the next section):
The trailing /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”:
Then read the status line, not the body: 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 probe carries no signature, so it can never open a session. That is the point: you are asking the route to prove it exists. What “alive” looks like per runtime:
A plain GET is not a health check for voice. Even locally and even with the bridge perfectly healthy, curl http://127.0.0.1:9442/msteams/calling returns 404 on OpenClaw: only an upgrade gets a real answer. If you want a plain-GET liveness check on the bridge port, Hermes serves GET /health and LiveKit and ElevenLabs serve GET /healthz, each returning 200 ok; OpenClaw has neither, so the --http1.1 upgrade probe above is the check there. Those health routes live at the root of the bridge port, so a path-scoped funnel mount does not publish them - run them against 127.0.0.1.

The bot answers calls but ignores Teams chat (DM pairing)

Symptom: voice works end to end, chat messages reach your agent (the gateway log shows received 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:
Approve your sender code:
Then send the message again. Alternatively set 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:
Hermes goes silent only when an allowlist is configured: with 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:
From ClawHub:
(Not plain 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.
Still stuck? Recheck the Quickstart end to end - most remaining issues are a config key living in the wrong section, or a Graph permission that was never admin-consented (see Hermes configuration).

Contact support

If none of the above resolves it, write to us and include the callId 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.