Prerequisites
- Python 3.10 or newer
- A StandIn identity and its connection secret, from standin.komaa.com
- A way to expose one local port over HTTPS and WebSocket: a tunnel such as Tailscale Funnel or ngrok, or an ingress in front of a container
Step 1: install
Step 2: set the secret
STANDIN_SECRET is the only required variable. It is what the handshake signature is checked against, and it is what arms the listener at all: without it, CallServer refuses to construct.
STANDIN_HOST=127.0.0.1 keeps the listener off every other interface while a local tunnel is the only thing reaching it. The default is 0.0.0.0, which is what you want in a container behind an ingress. The upgrade is HMAC-authenticated either way.
Those six are all the call and chat lanes read. The other lanes have their own, and
Configuration is the one page that lists every
STANDIN_ variable with
what owns it.
Configuration is environment-only by design, so credentials never reach a source file and the plugins all read their keys the same way.
Step 3: run the echo plugin
Step 4: expose the port
StandIn dials your worker, so the worker needs a publicwss:// address with the /msteams/calling path routed to port 9442.
Expose your agent has the mount command for Tailscale Funnel, ngrok,
cloudflared and devtunnel, and the probes that tell a registered mount from a working one. It is the
only page that carries those commands, because three divergent spellings of one URL across three pages
is what caused a live incident here.
Terminate TLS at your public ingress. The path has to match, because that is where the per-call callId segment is appended: StandIn dials wss://<your-host>/msteams/calling/{callId}.
Step 5: register the URL and call
In the StandIn portal, set your identity’s agent voice URL to the publicwss:// URL, including the /msteams/calling path. Then call the identity from Microsoft Teams and talk.
You should hear yourself, delayed by the round trip. The worker logs the call id and the caller’s display name when the call starts, and the close reason when it ends.
What just happened
If it does not answer
GET /healthz on the same port returns {"ok": true, "calls": <n>}, which is the cheapest way to confirm the listener is really bound.
Next
Write a handler
Replace the echo with your own agent loop.
Copy the echo plugin
The template, and how to turn it into your own plugin.
LiveKit
Two StandIn-specific lines in an ordinary LiveKit agent file.
Hermes Agent
Your Hermes agent, answering the phone in-process.