Install the bridge by hand

What backend/install.py does on Linux, step by step, for when you want to do it yourself or adapt it (other paths, no systemd). The paths and the unit are the installer's, so you can run the installer later to update; it keeps your bridge.env, unit and tokens. For macOS and Windows see On macOS and Windows, and for containers Docker. Phones reach the bridge by its bridge ID through Car Voice's relay (the phone link); an HTTPS address of your own is optional.

1. Switch on the Hermes API server

As in setup step 1: API_SERVER_ENABLED=true and a long random API_SERVER_KEY in ~/.hermes/.env, then restart the gateway (hermes gateway restart).

2. Install the bridge

The bridge needs Python 3.11 or newer (and, on Debian and Ubuntu, the python3-venv package). Download and check the release as in setup step 2, then put its backend/ in place:

sudo mkdir -p /opt/hermes-car-voice
sudo cp -r car-voice-bridge-0.7.0/backend /opt/hermes-car-voice/backend
cd /opt/hermes-car-voice
sudo python3 -m venv .venv
sudo .venv/bin/pip install -r backend/requirements.lock
sudo useradd --system --home-dir /var/lib/hermes-car-voice --shell /usr/sbin/nologin carvoice

requirements.lock pins every package. It includes faster-whisper for speech recognition on the machine itself; the first start downloads the Whisper model (a few hundred MB).

3. Configure the bridge

Create /etc/hermes-car-voice/bridge.env, readable only by root:

sudo mkdir -p /etc/hermes-car-voice
sudo install -m 600 /dev/null /etc/hermes-car-voice/bridge.env
sudoedit /etc/hermes-car-voice/bridge.env

with:

# Where the bridge listens. A tunnel or proxy on the same machine forwards to it.
HERMES_BRIDGE_HOST=127.0.0.1
HERMES_BRIDGE_PORT=8787
HERMES_BRIDGE_DATA_DIR=/var/lib/hermes-car-voice
# Access tokens for phones; `pair` (step 6) adds them.
HERMES_BRIDGE_TOKENS_FILE=/var/lib/hermes-car-voice/tokens.json

# Hermes API server from step 1.
HERMES_API_BASE_URL=http://127.0.0.1:8642
HERMES_API_ALLOW_INTERNAL_HTTP=true
HERMES_API_KEY=<the API_SERVER_KEY from step 1>

# Language for older app versions that do not send one (en is also the default).
# The app sends the phone's language with every question.
HERMES_BRIDGE_LANGUAGE=en

# Speech recognition: local Whisper on this machine ...
HERMES_BRIDGE_STT_PROVIDER=local
HERMES_BRIDGE_STT_MODEL=small
# ... or any OpenAI-compatible transcription service (much faster on a small CPU).
# Your recorded questions then go to that service.
# HERMES_BRIDGE_STT_PROVIDER=openai
# HERMES_BRIDGE_STT_API_BASE=https://api.groq.com/openai/v1
# HERMES_BRIDGE_STT_API_KEY=<key>
# HERMES_BRIDGE_STT_API_MODEL=whisper-large-v3-turbo

This is the file the installer writes. systemd reads it (EnvironmentFile=); do not load it into a shell with source, which breaks JSON values. Optional settings:

VariableDefaultWhat it does
HERMES_BRIDGE_TTS_VOICE_<LANG>built in for en, nl, de, fr, es, itThe Edge TTS voice per language, e.g. HERMES_BRIDGE_TTS_VOICE_EN=en-GB-SoniaNeural. Other languages use the English voice unless you set one.
HERMES_BRIDGE_SPEECH_MAX_CHARS_SHORT700Safety net on the length of a short spoken reply.
HERMES_BRIDGE_SPEECH_MAX_CHARS3000Safety net on a full spoken reply. Only a longer reply is cut, at a sentence, and only then does the voice say the full text is in the app.
HERMES_VOICE_INSTRUCTIONS / HERMES_VOICE_INSTRUCTIONS_FULLbuilt inReplace the instruction the bridge gives Hermes for short or full spoken replies.
HERMES_API_MODELhermes-agentSent as the run's model. Leave it unless you need another model; see "Per-request model selection" in the Hermes API server documentation.
TELEGRAM_BOT_TOKEN, TELEGRAM_DESTINATIONSoffOptional: send the full text of long replies to a Telegram chat. TELEGRAM_DESTINATIONS maps a phone name from the tokens file to a chat id, e.g. {"phone":"123456789"}.
HERMES_BRIDGE_LOG_FILEoff (stderr)Write the log to this file instead, rotated at 5 MB (three old files kept). The installer sets it on macOS and Windows, where there is no journal.
HERMES_BRIDGE_IROHonThe phone link over iroh through relay.carvoice.app. off leaves only an HTTPS address of your own.
HERMES_BRIDGE_IROH_KEY_FILEiroh-secret-key in the data directoryThe link's secret key (64 hex characters, mode 0600, made on first use). Its public half is the bridge ID; keep the file private and back it up with the tokens if you move the bridge.

After a change: sudo systemctl restart hermes-car-voice-bridge.

4. Run the bridge

Create /etc/systemd/system/hermes-car-voice-bridge.service (the installer writes the same unit):

[Unit]
Description=Car Voice bridge
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=carvoice
StateDirectory=hermes-car-voice
WorkingDirectory=/opt/hermes-car-voice/backend
EnvironmentFile=/etc/hermes-car-voice/bridge.env
ExecStart=/opt/hermes-car-voice/.venv/bin/python -m hermes_voice_bridge serve
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now hermes-car-voice-bridge
curl -s http://127.0.0.1:8787/readyz

/readyz answers {"status":"ready","hermes":"ok","stt":"ready","link":"online"} once Hermes answers, the Whisper model is loaded and the phone link reached the relay; "stt":"loading" means the model is still downloading or loading, "link":"connecting" that the relay was not reached yet. "hermes":"unreachable" means the API server from step 1 is not running, or HERMES_API_BASE_URL is wrong. /readyz does not check the key: a wrong key shows up on the first question, as "Hermes rejected the bridge key." in the app. Logs: journalctl -u hermes-car-voice-bridge. They never contain tokens, audio or what you said.

To check the configuration without starting the bridge:

sudo systemd-run --pipe --wait --quiet -p EnvironmentFile=/etc/hermes-car-voice/bridge.env -p User=carvoice \
  -p WorkingDirectory=/opt/hermes-car-voice/backend \
  /opt/hermes-car-voice/.venv/bin/python -m hermes_voice_bridge check

The bridge reaches the relay by itself (the phone link). Check that a phone can reach it, the bridge ID and the link's state:

sudo systemd-run --pipe --wait --quiet -p EnvironmentFile=/etc/hermes-car-voice/bridge.env -p User=carvoice \
  -p WorkingDirectory=/opt/hermes-car-voice/backend \
  /opt/hermes-car-voice/.venv/bin/python -m hermes_voice_bridge link-check

It prints {"link": "ok", "bridge_id": "iroh:…", …}. The same command with id instead of link-check prints only the bridge ID; id --rotate makes a new one (restart the bridge, then pair every phone again).

Only for an HTTPS address of your own (advanced): see setup: your own HTTPS address. With Tailscale, run on the bridge machine:

sudo tailscale serve --bg --https=443 http://127.0.0.1:8787

6. Pair your phone

On the bridge machine:

sudo systemd-run --pipe --wait --quiet -p EnvironmentFile=/etc/hermes-car-voice/bridge.env -p User=carvoice \
  -p WorkingDirectory=/opt/hermes-car-voice/backend \
  /opt/hermes-car-voice/.venv/bin/python -m hermes_voice_bridge pair --name phone

With an HTTPS address of your own, add --origin https://voice.example.com.

pair creates a random access token, adds it to the tokens file (mode 0600) and prints a one-time pairing code, a QR code, and the address (the bridge ID iroh:…, or your --origin) and token as text. The running bridge picks up the new token by itself. The pairing code works once, for 10 minutes; it is stored only as a salted hash in pairing-codes.json in the data directory. Add --code-only to print only the address and the pairing code, without the QR code and the token. Use --replace to renew the token of an existing name; the old token and its open pairing code then stop working. Treat the token like a password. sudo python3 /opt/hermes-car-voice/backend/install.py --pair runs the same command. Then connect the app as in setup step 5.

7. Updating

Download and check the new release as in setup step 2, then:

sudo rm -rf /opt/hermes-car-voice/backend
sudo cp -r car-voice-bridge-0.7.0/backend /opt/hermes-car-voice/backend
sudo /opt/hermes-car-voice/.venv/bin/pip install -r /opt/hermes-car-voice/backend/requirements.lock
sudo systemctl restart hermes-car-voice-bridge

Or, the same in one step with a backup of the previous code: sudo python3 car-voice-bridge-0.7.0/backend/install.py. It keeps your bridge.env, unit and tokens.

On macOS and Windows

There the installer runs as you, without sudo or admin rights, and keeps everything in one folder: ~/Library/Application Support/Car Voice Bridge on macOS, %LOCALAPPDATA%\Car Voice Bridge on Windows (backend, .venv, bridge.env, data, logs). The bridge reads its settings itself:

python -m hermes_voice_bridge --env-file bridge.env serve    # also: check, pair, id, link-check

run in the backend folder with the virtual environment's Python (.venv/bin/python on macOS, .venv\Scripts\python.exe on Windows). bridge.env has the same settings as above, with the paths of that folder and HERMES_BRIDGE_LOG_FILE pointing at logs/bridge.log; on Windows the installer makes it readable only by you (icacls … /inheritance:r /grant:r <you>:F).

The service the installer registers:

To see the exact files and commands for your computer without changing anything, run the installer with --dry-run; --dry-run --platform macos, --platform windows or --platform container shows the plan for another platform.

Docker

If Hermes itself runs in Docker (Hermes' image) or on a hosted server, the installer's container mode puts the bridge into the Hermes container instead, started by a Hermes gateway hook: see Hermes in Docker or on a hosted server. What follows is a separate bridge container next to a Hermes that runs directly on the computer.

backend/deploy/docker-compose.yml runs the bridge in a container that reads the same bridge.env and keeps its data in a volume. The bridge has to reach Hermes' API server, which normally listens only on 127.0.0.1 of the computer:

For pairing, run pair inside the container with the same settings (docker compose exec hermes-voice-bridge python -m hermes_voice_bridge pair; the tokens file must be in the data volume, e.g. HERMES_BRIDGE_TOKENS_FILE=/data/tokens.json, and so is the link's key). The phone link needs only outgoing connections, which a container has by default. The installer is the easier route on every platform.