Skip to main content

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:

  1. Loads env files. .env in the current directory is loaded first, then ~/.voxa/.env is loaded on top of it as overrides. This is what lets a self-hoster keep persistent settings in ~/.voxa/.env regardless of which directory they launch voxa from.

  2. Applies zero-config defaults. If you haven't set VOXA_RELAY_URL and Tailscale is available, Voxa leaves the relay unset and prefers the direct Tailscale path. Otherwise it defaults VOXA_RELAY_URL to the hosted relay and VOXA_LIVE_PROXY to the hosted /live proxy. If VOXA_AUTH_TOKEN isn't set, a stable per-machine token is generated and persisted. If VOXA_RELAY_CODE isn't set, a stable 128-bit pairing code is generated and persisted.

  3. 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_PROXY in ~/.voxa/.env and exits with an error.

  4. Checks for cloudflared. Only if no relay URL is set (self-hosted, no Tailscale) and cloudflared isn't on your PATH: it prints install instructions (brew install cloudflared) and exits with an error, since the fallback tunnel path needs it.

  5. 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 set VOXA_PORT

    and exits with an error.

  6. Starts the server. It launches uvicorn as a subprocess, bound to 127.0.0.1 on the configured port. This bind is hardcoded: the server always listens on loopback only, never on your LAN, regardless of VOXA_HOST.

  7. Polls for health. It polls the local /healthz endpoint 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).

  8. 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.

  9. Picks a transport for the phone to reach the laptop, trying these in order:

    • Relay dial-out, if VOXA_RELAY_URL is set (the default, whether hosted or pointed at your own server_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 serve to 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.

Pairing URL shapes

The URL printed as a QR code (and as text underneath it) depends on which transport was chosen:

TransportURL shape
Relayhttps://api.voxa.space/?code=<relay-code>&token=<auth-token>
Tailscalehttps://<machine>.<tailnet>.ts.net/?token=<auth-token>
Cloudflare tunnelhttps://<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, and cloudflared isn'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 /healthz successfully.
  • 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 particular mute_finish and mute_blocked, the two keys that silence a project (the phone's mute toggle writes them), plus the ladder's timing knobs. A pre-existing notify_rules.json from an older Voxa is migrated into this automatically the first time you run voxa, 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 .env exists in the current directory.

All of this stays on your laptop. None of it is uploaded anywhere as part of normal operation.