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:
| Variable | Default | What it does |
|---|---|---|
HERMES_BRIDGE_TTS_VOICE_<LANG> | built in for en, nl, de, fr, es, it | The 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_SHORT | 700 | Safety net on the length of a short spoken reply. |
HERMES_BRIDGE_SPEECH_MAX_CHARS | 3000 | Safety 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_FULL | built in | Replace the instruction the bridge gives Hermes for short or full spoken replies. |
HERMES_API_MODEL | hermes-agent | Sent 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_DESTINATIONS | off | Optional: 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_FILE | off (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_IROH | on | The phone link over iroh through relay.carvoice.app. off leaves only an HTTPS address of your own. |
HERMES_BRIDGE_IROH_KEY_FILE | iroh-secret-key in the data directory | The 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
5. Check the phone link
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:
- macOS: the LaunchAgent
~/Library/LaunchAgents/app.carvoice.bridge.plist, which launchd starts at login (RunAtLoad) and again when it stops (KeepAlive); launchd's own output goes tologs/launchd.log. By hand:launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.carvoice.bridge.plistloads it,launchctl bootout gui/$(id -u)/app.carvoice.bridgestops and unloads it. - Windows: the scheduled task "Car Voice Bridge" from
Car Voice Bridge.xmlin that folder: at your logon, as you without elevation, withpythonw.exe(no console window), no time limit, also on battery, restarted every minute when it fails. By hand:schtasks /Create /TN "Car Voice Bridge" /XML "Car Voice Bridge.xml" /F, thenschtasks /Run /TN "Car Voice Bridge";schtasks /Endstops it andschtasks /Deleteremoves it.
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:
Linux (Docker Engine): use host networking (
network_mode: host; Docker documents that the container then shares the host's network), soHERMES_API_BASE_URL=http://127.0.0.1:8642reaches Hermes, and setHERMES_BRIDGE_HOST=127.0.0.1. Without host networking,host.docker.internalneedsextra_hosts: ["host.docker.internal:host-gateway"], which points at the Docker bridge's address, where a Hermes that listens only on127.0.0.1is not reachable.Docker Desktop (macOS, Windows): Docker documents
host.docker.internalas the name for services on the host (Docker). SetHERMES_API_BASE_URL=http://host.docker.internal:8642andHERMES_API_ALLOW_INTERNAL_HTTP=true; the bridge accepts that address. Docker's documentation does not say whether a host service that listens only on127.0.0.1is reachable this way, and this has not been tested with Car Voice. Check it from the container:docker compose exec hermes-voice-bridge python -c "import urllib.request; print(urllib.request.urlopen('http://host.docker.internal:8642/health').read())"
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.