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_SECRETis 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 answers401to every call while holding a byte-perfect secret.verify_handshake/verifyHandshakefails closed and says nothing, so this looks exactly like a mismatch. Comparedate -uagainst 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 yourwss:// endpoint, there is
nothing to bridge to and no bot joins.
Fix, in order:
- 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).
- 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/healthzshould answer{"ok": true, "calls": 0}. - Is the bind reachable?
STANDIN_HOST=127.0.0.1is invisible from the internet unless a tunnel forwards to it. Either keep the tunnel in front, or bind0.0.0.0behind an ingress. - 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.
- 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.
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: callcancel_playback() / cancelPlayback() first, then cancel the provider:
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. UseFrameAligner 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 insession.starton every call. Pass it asthread_id/threadIdwhen you build the gate, and treatnote_participants()/noteParticipants()as corroboration only. It can add certainty; it must never remove it.
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 sentsession.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 isno-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 iscall-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 ismax_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 byChatChannel, 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 underSTANDIN_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:
ChatChannelmust 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.
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. WithdmPolicy: pairing, DMs from unapproved senders
are dropped and a pairing request is queued without telling the sender. List and approve:
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: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.
Contact support
Include thecallId 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.