CLI
voxa is a zero-argument launcher. It takes no flags and no subcommands, whether you run it as voxa, npx voxa-code, or python -m server.cli from a source checkout. Its entire interface is environment variables: what it does on any given run is controlled by what's set in .env, ~/.voxa/.env, or your shell environment. See the configuration reference for the full list.
Startup sequence
Running voxa does the following, in order:
-
Loads env files.
.envin the current directory is loaded first, then~/.voxa/.envis loaded on top of it as overrides. This is what lets a self-hoster keep persistent settings in~/.voxa/.envregardless of which directory they launchvoxafrom. -
Applies zero-config defaults. If you haven't set
VOXA_RELAY_URLand Tailscale is available, Voxa leaves the relay unset and prefers the direct Tailscale path. Otherwise it defaultsVOXA_RELAY_URLto the hosted relay andVOXA_LIVE_PROXYto the hosted/liveproxy. IfVOXA_AUTH_TOKENisn't set, a stable per-machine token is generated and persisted. IfVOXA_RELAY_CODEisn't set, a stable 128-bit pairing code is generated and persisted. -
Loads and validates config. If required configuration is missing (for example, no Gemini key and no live proxy), it prints a message pointing you at
VOXA_RELAY_URL/VOXA_LIVE_PROXYin~/.voxa/.envand exits with an error. -
Checks for
cloudflared. Only if no relay URL is set (self-hosted, no Tailscale) andcloudflaredisn't on your PATH: it prints install instructions (brew install cloudflared) and exits with an error, since the fallback tunnel path needs it. -
Checks the port. If something is already bound to the configured port on
127.0.0.1, it prints:Port
<port>is already in use, another Voxa may be running...pkill -f 'server.cli'or setVOXA_PORTand exits with an error.
-
Starts the server. It launches uvicorn as a subprocess, bound to
127.0.0.1on the configured port. This bind is hardcoded: the server always listens on loopback only, never on your LAN, regardless ofVOXA_HOST. -
Polls for health. It polls the local
/healthzendpoint repeatedly until it returns success. If the server never comes up, it prints that the server didn't come up, cleans up, and exits with an error (no QR code is shown). -
Installs the Claude Code hook. Unless disabled, it installs a global Claude Code hook pointing at the local server with the current auth token, so a finished (or blocked) Claude task can notify Voxa. This step is non-fatal: if it fails, it prints a note but keeps going.
-
Picks a transport for the phone to reach the laptop, trying these in order:
- Relay dial-out, if
VOXA_RELAY_URLis set (the default, whether hosted or pointed at your ownserver_cloud). The laptop dials out to the relay; nothing needs to be open inbound. - Tailscale serve, if no relay is configured and Tailscale is available. Voxa runs
tailscale serveto expose the local server over your tailnet with HTTPS, then verifies it's actually reachable. If it isn't, it falls back to a tunnel. - Cloudflare quick tunnel, as the last resort. It spawns
cloudflared, waits for a public URL to appear, and confirms it's reachable, retrying up to 3 times before giving up.
- Relay dial-out, if
Pairing URL shapes
The URL printed as a QR code (and as text underneath it) depends on which transport was chosen:
| Transport | URL shape |
|---|---|
| Relay | https://api.voxa.space/?code=<relay-code>&token=<auth-token> |
| Tailscale | https://<machine>.<tailnet>.ts.net/?token=<auth-token> |
| Cloudflare tunnel | https://<random>.trycloudflare.com/?token=<auth-token> |
Any of these can be opened directly in a phone browser instead of scanned; both the native app and a plain browser tab understand the same URL.
Ctrl-C and cleanup
Pressing Ctrl-C (or sending SIGTERM) triggers cleanup: the uvicorn subprocess is terminated, and if a Cloudflare tunnel was opened, that subprocess is terminated too. If the Tailscale path was used, tailscale serve --https=443 off is run to tear down the tailnet exposure. This same cleanup also runs automatically at process exit, so it fires even if something else kills the process.
Note that stopping voxa is different from a phone disconnecting. The Claude Code session your phone was talking to keeps running as long as voxa itself stays up; only stopping voxa tears the whole thing down.
Exit-with-error cases
voxa exits with a non-zero status (and prints a specific message) in these cases:
- Missing
cloudflared. No relay configured, Tailscale unavailable, andcloudflaredisn't installed. - Port already in use. Another process (possibly another
voxa) is already bound to the configured port. - No working tunnel after 3 tries. The Cloudflare quick tunnel path was attempted three times and never became reachable.
- Server never healthy. The local uvicorn process never answered
/healthzsuccessfully. - Missing required configuration. Neither a Gemini key nor a live proxy URL is configured, so there's no way to run voice at all.
Where state lives on disk
Voxa keeps its persistent state under ~/.voxa/:
auth_token: the generated (or configured) shared secret the phone presents on its WebSocket connection.relay_code: the generated 128-bit pairing code used to match your phone to your laptop through the hosted relay.machine-id: this laptop's stable identifier, used for the Connected Macs roster and push routing.interrupt_policy.json: your per-project interrupt policy, in particularmute_finishandmute_blocked, the two keys that silence a project (the phone's mute toggle writes them), plus the ladder's timing knobs. A pre-existingnotify_rules.jsonfrom an older Voxa is migrated into this automatically the first time you runvoxa, and left in place afterward.hooks.jsonl: every hook event and every interrupt decision Voxa made, with the reason and why. This is what to read when tuning the interrupt policy, or figuring out why (or why not) you got a call.history/voice.jsonl: local voice session history..env(optional): persistent overrides loaded on every launch, on top of whatever.envexists in the current directory.
All of this stays on your laptop. None of it is uploaded anywhere as part of normal operation.