What you publish
The WebSocket is the only thing that has to be publicly reachable. StandIn dials
/msteams/calling/{callId}, appending the call id itself. Both defaults are overridable with
STANDIN_PORT and STANDIN_WS_PATH, but change them only if something else already owns the port:
one URL shape across every worker is worth more than a tidier path.
The second row needs no second mount. It sits under the same prefix, so a path mount already
forwards it, and a whole-port mount does too. It exists only when your worker registers an outcome
callback, on_call_outcome / onCallOutcome, which is how a worker that places calls hears
that nobody answered instead of waiting out the ring. Register nothing and a POST there is a
404, so a worker that only answers calls opens no extra surface. It is signed like everything
else: a bad or stale signature is 401, a body over the cap is 413, and a good one is 204.
Outbound calling is covered in Reaching people (Python,
TypeScript).
Two things are not on the list, for two different reasons. GET /healthz sits at the root of
the call port rather than under the call path, so a path mount does not carry it, which is the
point: it is a local check (see Probe it by hand). A whole-port tunnel does
carry it, along with everything else on that port, which is one more reason to prefer a path mount.
And placing an outbound call is your worker dialling out to the StandIn address in
STANDIN_WORKER_URL, so there is nothing inbound to publish for it.
The chat lane needs no mount.
ChatChannel dials out from your worker to StandIn, so there
is no inbound chat path, no port and no tunnel rule for chat. See
Architecture.Mount the call path
With Tailscale Funnel, map the path onto your one public host. The proxy target must repeat the path, or the request arrives at your worker stripped and 404s:STANDIN_HOST=127.0.0.1 in the worker so the listener is reachable only through the funnel.
The SDK’s own default is 0.0.0.0, which suits a container behind an ingress and is too open for a
laptop.
Confirm it before you register anything:
Other tunnels
ngrok, cloudflared and devtunnel forward a whole local port rather than a path:/msteams/calling.
Behind an ingress
In a cluster, point an ingress rule at the worker’s service on9442 and leave STANDIN_HOST at
its 0.0.0.0 default. The ingress must allow WebSocket upgrades and must not strip the path. Read
timeouts need to outlast a call, not a request: a long silence in a conversation is normal, and an
idle timeout below your longest expected pause will cut live calls.
Check the message size limit too. The listener accepts a single inbound WebSocket message of up to
2 MB, and what crosses that socket is wildly uneven: one audio frame is under a kilobyte, while
one frame of the caller’s camera or screen share is orders of magnitude larger. A proxy that caps a
message below 2 MB therefore breaks video on a call whose audio keeps working perfectly, and that is
a hard symptom to attribute to the proxy.
What you register in StandIn
One field on your connection in the dashboard:
Paste a bare host and StandIn appends the default path. Paste a full URL and it is respected as
given. With a path mount there is no port in the value.
The Azure Bot calling webhook (
https://<identity>.standin.komaa.com/api/calling) is a StandIn
URL you set in Azure. It is not the worker URL on this page. Set the webhook in
Create the Azure bot. Register the worker URL above in the StandIn dashboard.Probe it by hand
/probe stands in for a call id. The probe carries no signature, so it can never open
a session: you are asking the route to prove it exists, not to let you in.
Run the same probe against
http://127.0.0.1:9442/msteams/calling/probe to tell “the tunnel is
wrong” from “nothing is listening”.
For a plain liveness check the SDK also serves GET /healthz at the root of the call port. A
path-scoped funnel mount does not publish it, which is deliberate: it is a local check, so run it
against 127.0.0.1.
calls count is the number of live sessions, which is also how you watch a drain finish.