Skip to main content
Work top to bottom: secret, reachability, address, then how the agent behaves once calls do connect, then limits. Most first-call failures are one of the first two, and both of them are silent by design.

The bot joins but never speaks (HMAC mismatch)

Symptom: the bot joins the call, then nothing. No greeting, no reply, no error in your worker’s logs. STANDIN_SECRET must byte-match the secret registered for your identity in StandIn. A mismatch fails the handshake and the connection is dropped, so both sides look healthy while no audio flows. This is the number one first-call issue. It is silent on purpose. An endpoint that explains why it rejected you is an endpoint that helps an attacker, so the worker answers 401 and says nothing more. Fix:
  • Re-paste the secret on both sides. Watch for a trailing newline picked up when copying, and for shell quoting that eats characters such as $ or !. Prefer single quotes: export STANDIN_SECRET='...'.
  • Confirm the variable actually reaches the process. STANDIN_SECRET is read at construction, so a value exported in a different shell, or set after the worker started, does nothing.
  • The secret is not re-read hot. Restart the worker after changing it. A rotated secret takes effect on the next handshake, so live calls finish on the old one and the next call in uses the new one. Nothing has to be re-registered and no support request is needed.
  • Check the worker’s clock. The handshake signs a timestamp alongside the callId, and both SDKs refuse one more than 60 seconds away from their own clock, in either direction, before they compare signatures at all. A host whose clock has drifted, or a container resumed from a suspended image, therefore answers 401 to every call while holding a byte-perfect secret. verify_handshake / verifyHandshake fails closed and says nothing, so this looks exactly like a mismatch. Compare date -u against a known good source, and run a time sync service. The window and why it is that tight are in Security (Python, TypeScript).

Bot does not join: “agent is not reachable”

Symptom: the call never gets a bot, and the portal says the agent is not reachable. StandIn dials your worker from the internet. If it cannot reach your wss:// endpoint, there is nothing to bridge to and no bot joins. Fix, in order:
  1. Does your own handler answer at all? Run the install check before you suspect the network. It binds an ephemeral listener on loopback, speaks the real call wire to your handler, streams a few frames and reports whether audio came back, with no tunnel, no provider bill and no Microsoft tenant involved. A worker that fails there was never going to answer a real call, and no amount of tunnel debugging changes that. See Checking the install (Python, TypeScript).
  2. Is the worker running? Start it and confirm the startup line naming its bind address and path. curl -s http://127.0.0.1:9442/healthz should answer {"ok": true, "calls": 0}.
  3. Is the bind reachable? STANDIN_HOST=127.0.0.1 is invisible from the internet unless a tunnel forwards to it. Either keep the tunnel in front, or bind 0.0.0.0 behind an ingress.
  4. Is the tunnel up? A dropped tunnel is the most common cause after the secret. Restart it and test the public URL from outside your network.
  5. Does the registered URL match? The agent calling URL in StandIn must point at the live endpoint, including the path. See Expose your agent.

A voice endpoint probes as 404 through a funnel

Symptom: tailscale funnel status shows the rule, the worker is running, and a curl probe with WebSocket headers still returns 404, which looks exactly like a missing mount. This is an HTTP/2 false negative. The probe 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 never travel as a handshake: what reaches the worker is an ordinary GET, and a TypeScript worker answers that 404 because nothing but an upgrade belongs on that path. So the 404 is about the probe, not about the mount. A Python worker answers the same degraded probe 401, because it verifies the signature before it looks at the upgrade. That is the more dangerous outcome: it looks like a pass. It does prove the mount reaches your worker, and it proves nothing about whether the tunnel forwards a real upgrade, which is the only thing a call needs from it. Force HTTP/1.1 either way, and include a call id segment:
401 is the healthy answer: the route exists and refused an unsigned probe.
The bare path 404s on a healthy worker. The SDK registers the route as /msteams/calling/{callId}, so /msteams/calling with no segment has no route at all. A path mount forwards everything underneath it, so the /probe segment reaches the worker either way.
A plain GET is not a health check for calls, and its status code differs by SDK. With a call-id segment and no upgrade, a healthy worker on loopback answers 401 in Python and 404 in TypeScript: the Python listener verifies the signature before it ever looks at the upgrade, and the TypeScript listener only ever routes upgrades there. Neither is a liveness signal. Use GET /healthz at the root of the call port for liveness, and the upgrade probe above for routing.

Wrong port or path

A URL registered with a stale port, or with the path dropped by the proxy target, connects to nothing and the call never reaches your agent. Register the public wss:// URL, never the local ws:// bind. With a path mount there is no port in the value; with a whole-port tunnel there is, and you have to type it correctly everywhere, which is one of several reasons to prefer the path mount.
Each call carries its own callId, and the handshake signs it per call. You never configure a callId. If logs show signature failures for one specific callId, that is still the shared secret, not the id.

Calls connect but the bot talks over the caller

Symptom: the caller interrupts, the model stops generating, and the bot keeps talking for another second or two anyway. Your handler cancelled the response upstream without cancelling the playback. Audio already handed to StandIn is still queued at the service, and nothing upstream can reach it. Fix: call cancel_playback() / cancelPlayback() first, then cancel the provider:
It is the only wire-level lever that un-sends buffered audio. See Dialogue modes.

The last word of every turn is clipped

Symptom: replies sound fine except that the final syllable is missing, consistently, on every turn. A resampled buffer does not divide evenly into the wire’s 640-byte frame, and the remainder is being dropped. Use FrameAligner and flush it at the end of a turn rather than discarding the tail. This is exactly why the aligner is in the SDK: see Architecture.

The bot answers every turn of a meeting

Symptom: in a 1:1 call the agent behaves. Invited to a meeting, it replies to everything anybody says, and the meeting cannot continue until somebody mutes or removes it. The group gate is not doing anything. GroupGate keeps the agent quiet until somebody addresses it by a wake phrase, then leaves the floor open for a follow-up window, 12 seconds by default, so the next sentence does not need the name again. A 1:1 call always answers. Two mistakes switch the gate off without looking like they did:
  • No wake phrase configured. A gate with nothing that can open it would mute the agent for the whole call, so a gate whose phrase list is empty treats itself as off and answers everything. Configure the phrases your callers will actually say.
  • Deciding “is this a group call?” from the participant count. A participant count never arrives on the meeting-join path, so a gate that waits for one is dead on exactly the calls it exists for, and the failure reads as a gate that was never switched on. The signal that does arrive is the thread id: Microsoft’s id for a meeting or channel conversation begins 19:, and it is in session.start on every call. Pass it as thread_id / threadId when you build the gate, and treat note_participants() / noteParticipants() as corroboration only. It can add certainty; it must never remove it.
Group calls (Python, TypeScript) has the gate, the follow-up window and the verbal interrupts in full.

The agent answers itself, and the caller cannot get a word in

Symptom: the caller is on a speakerphone, or on a laptop with no headset. The agent says something, then answers itself, then answers that, in a loop, with the caller silent throughout, until somebody hangs up. The agent’s own voice is coming out of the caller’s speaker and back into the caller’s microphone. It arrives at your handler as caller audio, the provider’s voice detection hears a finished turn, and the model replies to it. This is not a subtle degradation: the call has been taken over. Fix: run the echo guard over inbound frames. It keeps a playout clock, and that is the part worth understanding: wall-clock send time is useless here, because a realtime model streams audio far faster than realtime, so a guard keyed on “when did we last send” disarms in the middle of the sentence it exists to cover. The guard accumulates the duration of what was sent instead, and while that playout is still likely to be audible it drops inbound audio unless the frame is loud enough to be a person really interrupting: ECHO_BARGE_IN_RMS, 0.04, with a tail of ECHO_SUPPRESSION_WINDOW_MS, 600 ms, for the network and jitter buffer between you and their speaker. Until the caller’s first real turn, no barge-in is allowed at all, because the opening greeting echoing back is exactly the loud thing that would otherwise make the agent interrupt and re-greet itself. In Python the guard tracks that itself once you call mark_caller_turn(); in TypeScript you pass allowBargeIn: false until the caller has spoken. The two SDKs are genuinely different shapes here, and a port that assumes otherwise runs and then fails on a real call: Python has an EchoGuard class that owns its clock, TypeScript has shouldSuppressEcho, a pure function whose answer is inverted, true meaning drop the frame, and whose playout horizon you keep yourself on the epoch-millisecond scale. Realtime providers (Python, TypeScript) has both, side by side.

The caller’s first sentence never reaches the agent

Symptom: the agent opens by asking something the caller already answered. Or the group gate never engages, or a recording gate never opens, even though the context did arrive. A call starts the moment StandIn dials. Your agent starts a little later: a socket has to open, a room has to be joined, a provider session has to be configured. Everything the caller does in that gap still arrives, and if nothing holds it, it is gone. People start talking the instant a call connects, and the first thing they say is often the reason they called. The “there are three people here, stay quiet unless addressed” sentence and the recording-status change land in the same gap. Fix: hold what arrives and release it when the agent is ready. StartupBuffer keeps caller audio and call context in two lanes, preserves order within each, and releases audio before context, because the provider needs the words in the order they were said and the context is a note about the call rather than part of the conversation. It is bounded on purpose: MAX_PENDING_AUDIO is 200 frames, about four seconds of speech, and MAX_PENDING_CONTEXT is 20 lines. It drops the oldest rather than the newest, so if something has to be lost it is the stale audio rather than what just happened, and a socket that never opens cannot grow a buffer for the length of the call. Log dropped when it is not zero: it means your startup took longer than the buffer covers, which is a different problem from the one this fixes. Realtime providers (Python, TypeScript) has the buffer and its release order.

The call ends by itself after about 45 seconds

That is the caller-audio idle watchdog, and it is doing its job: a live call that has stopped carrying caller audio is a call nobody is on. If it is firing during real conversation, caller audio is not arriving, which usually means the media path degraded rather than that the timer is wrong. The related timers are a 10 s pre-start watchdog, for a socket that authenticated and never sent session.start, and a 15 s on_start timeout, for a handler whose startup hangs. A handler that routinely needs longer than 15 s to be ready should return from on_start early and finish warming up in the background. Five timers can end a call on their own. The other two have their own sections below, because they look nothing like this one: the unanswered-call reaper at about two minutes, and the call-duration ceiling you set yourself.

The bot joins, stays silent, and the call drops after about two minutes

Symptom: the call connected, the caller talked the whole time, the agent never said anything, and at around 120 seconds the call ends. The close reason is no-agent-answered, and your log carries the line that names the call. Each SDK prints the timer in its own unit, so grep for nothing answered call rather than for a number: Python logs standin: nothing answered call <id> within 120s; ending it and TypeScript logs the same sentence with within 120000ms. Nothing ever answered the call. This is the gap none of the other watchdogs cover: the pre-start watchdog is satisfied because session.start arrived, the on_start timeout is satisfied because on_start returned, and the idle watchdog is satisfied because the caller is still talking. No audio ever went out, which is what a dispatch that never lands, or a provider session that never opened, looks like from the listener’s side. Fix: the cause is on your side of the seam, so find out why the agent produced no audio. Sending audio counts as answering, so a handler that speaks at all is covered without doing anything. A plugin that answers by a route the listener cannot see, joining a room or handing the call to a provider, has to say so: call session.mark_answered() / session.markAnswered() when the agent’s own audio track appears, not when any participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering. The timer is stale_call_reaper_seconds, default 120.0, and staleCallReaperMs, default 120_000. Two minutes either way, but the names carry their unit and a value copied across the two SDKs unchanged is off by a thousand: staleCallReaperMs: 120 reaps every call within a quarter of a second of connecting. 0 disables it in both, and disabling it is rarely what you want: it only ever frees a call that is already lost, and it costs nothing on a call that was answered.

The call ends politely at a fixed time

Symptom: the call ends at the same elapsed time every time, and the caller hears a closing line first, by default “We are out of time on this call, so I have to stop here. Goodbye.” The close reason is call-duration-limit. That is the call-duration ceiling, max_call_seconds / maxCallMs. It is off by default, so if you are seeing it, you or your plugin’s configuration set it. It is a hard ceiling measured from session.start, and it is a different thing from the idle watchdog: idle ends a call that went quiet, this one ends a call that is still going. The caller who will not hang up, the model looping at itself and the automated system that dialled and never stopped talking all bill a provider by the minute for as long as the socket lives, and none of them trips a silence check. The line is goodbye_text / goodbyeText, delivered through the handler’s on_goodbye, the same callback StandIn’s own closing line uses, so no plugin needs new code to honour it. Playback is flushed first, or the line would queue behind whatever audio is still in flight and the call would end before anyone heard it. How long it has to finish is goodbye_grace, default 6.0, and goodbyeGraceMs, default 6000: six seconds either way, and the name carries the unit here too.

Calls are refused with 503

The worker is draining, or every slot is taken. Both are deliberate: a worker that refuses a call it cannot serve beats one that accepts it and half-serves it. Capacity is checked before any signature work, so a flood of calls you were never going to take costs no crypto. The ceiling is max_connections / maxConnections, 64 by default. It is a constructor argument and not an environment variable, so raising it is a code change and no STANDIN_ setting will do it: call server (Python, TypeScript). Watch GET /healthz for the live call count to see a drain finish. A 409 instead means that call id already has a live session, which happens if a previous socket for the same call has not finished tearing down.

Voice works, chat is silent, and nothing appears in any log

The diagnosis is this: the chat lane is dialed out by ChatChannel, so there is no mount to get wrong and no inbound request to lose. An empty log means the channel was never started, or it failed to open. Check that you called start() on it, that a secret is set in that process too, and that your identity is on the StandIn AI Teammate app: the chat lane exists only there. The lane reads STANDIN_CHAT_SECRET first and falls back to STANDIN_SECRET, so a StandIn AI Teammate app connection that issued a separate chat key needs that key here. Unset STANDIN_CHAT_SECRET rather than blanking it: in TypeScript an exported empty string counts as set and shadows a perfectly good STANDIN_SECRET, so the channel refuses to start at all, while in Python the blank falls through and the lane opens. Then look for the standin: chat channel open line at startup. Without it, nothing is connected.

Meeting recap is on, the call ended, and nothing landed in chat

The plugin wrote the transcript (look under STANDIN_RECAP_DIR or ~/.standin/state/recap) and then had nowhere this SDK can post.
  • Bring your own Azure bot / Free identity: chat is your Azure Bot messaging URL, not ChatChannel. This switch will not post there. The host must also have a summarization consult.
  • StandIn AI Teammate app: ChatChannel must actually open. Same checks as chat is silent: secret, start(), and a chat key if StandIn issued a separate one. Recap opens the lane listen-only, so it will not answer chat even when posting works.
Hang-up does not await the consult. Meeting recaps are best-effort. They may be lost if the worker exits during processing.

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

Symptom: voice works end to end, the message reaches your agent, and the bot never replies. Your agent declined to act on it. Both runtimes gate direct messages from senders they do not know, and this is their gate rather than StandIn’s. OpenClaw: closed by default, and silent. With dmPolicy: pairing, DMs from unapproved senders are dropped and a pairing request is queued without telling the sender. List and approve:
Hermes: gated too, but visible by default. With no allowlist, an unknown sender gets a pairing code back instead of an answer. Approve it once:
Hermes goes silent only when TEAMS_ALLOWED_USERS (or the global GATEWAY_ALLOWED_USERS) is set: unlisted senders are then dropped with an unauthorized-user warning and no pairing code is offered.
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 have.

A picture is refused on its way into a chat

Symptom: the agent tries to attach an image and gets an error back instead, so the reply arrives without it, or not at all. Every outbound picture is checked before it is sent, and there are three messages, each meaning something different: A local file has one gate before all three. Reading one is off until you name a directory, and until you do, sending a local file is off until a directory is named in STANDIN_MEDIA_ROOTS is the answer. A path outside those roots is refused with that file is outside the directories this worker may read, with symlinks resolved first, because the path reaching that call was chosen by a model somebody else is steering. Sending a picture (Python, TypeScript) covers the checks and the filename rules. For a picture arriving the other way, see chat attachments (Python, TypeScript).

An outbound call is refused before it rings

Symptom: the agent says it cannot call somebody, and no phone rings anywhere. That is the outbound policy, and it is working. Everything that can be refused is refused before the call is placed, so a refusal never leaves somebody’s phone ringing for a message that was rejected. Each refusal is one sentence, safe to read out loud: Fix: put the directory id in STANDIN_OUTBOUND_ALLOW, comma separated, and restart the worker. The list is read when the policy is built. This list is separate from any inbound allowlist, and stricter, because the two answer different questions. Inbound asks whether a person may talk to the agent, and that person chose to dial. Outbound asks whether the agent may ring a person, and the agent was talked into it by whoever is on the call. An agent with an outbound tool and no allowlist is an agent that can be talked into cold-calling your directory. Reaching people (Python, TypeScript) has the rest of the lane.

Free identity: bot leaves or will not join (daily budget)

A free identity is metered: 5 minutes of call time per day, per identity. When the budget runs out the bot leaves and returns the next day. The cutoff is abrupt: at the 5-minute mark the bot leaves immediately, even mid-sentence, with no warning. A bot that “hung up out of nowhere” at around five minutes is the budget, not a fault. “The next day” flips at midnight UTC, not your local midnight, which is why a bot can work again “early in the morning” or “only after lunch” without anything having changed.

A package name will not resolve

There are two package names, one per language, and everything ships inside them:
Framework support is an extra or a subpath of those, never a separate package: standin-sdk[livekit], standin-sdk[hermes-agent], @komaa/standin-sdk/openclaw. Two Python extras gate a shipped capability rather than a framework, and a base install hits a missing dependency the first time it uses one: standin-sdk[tile] encodes frames for the avatar tile, and standin-sdk[render] draws a document onto that tile. Office formats additionally need LibreOffice on PATH, which is not something pip can fetch. standin-sdk[all] pulls all of them in at once.
Still stuck? Re-read the quickstart end to end. Most remaining issues are a variable that never reached the process, or a Graph permission that was never admin-consented.

Contact support

Include the callId from your worker’s logs, which SDK and plugin you are running, 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.