
How to connect the Hermes Desktop App to a Remote Hermes Backend (the clean way)

Running the Hermes agent on a spare always-on machine (a home server, a mini PC, or in my case a MacBook Air sitting on a shelf) and driving it from the native desktop app on your laptop is a great setup. The agent gets a stable home with your files, sessions and tools, and you get the native UI from anywhere.
Getting this working used to mean SSH tunnels, Cloudflare proxies and Host-header rewrites. Not anymore. A recent Hermes update made it clean. Here is how to connect the desktop app to a remote Hermes backend, why it was painful before, and how to expose it safely over Tailscale.
โ ๏ธ Version requirement. You need a recent Hermes build on the server (June 2026 or later). Older builds reject the desktop app's WebSocket and you will hit the reconnect loop below. Just run
hermes update.
TL;DR
On the machine running Hermes, pin the session token in ~/.hermes/.env so it survives restarts:
HERMES_DASHBOARD_SESSION_TOKEN=your-long-random-token-here
Then start the dashboard:
hermes dashboard --host 0.0.0.0 --port 9119 --insecure --tui --no-open --skip-build
Then in the desktop app, go to Settings > Gateway > Remote connection and enter:
- Remote URL:
http://<server-ip>:9119 - Session Token: the value above
The rest of this post walks through it properly, in order.
Why this used to be hard
The desktop app is an Electron shell. When it connects to a remote dashboard it does two things: a REST handshake (GET /api/status), then a live WebSocket for the chat and event stream. The REST call almost always works, which is why "Test connection" looks fine. The WebSocket has three extra guards that quietly reject it:
- The chat WebSocket is off by default. Without
--tui, the embedded-chat channels (/api/ws,/api/events) do not exist, so the upgrade returns403. The app connects, the socket fails, and it loops forever on "reconnecting / repair". - A Host-header allowlist (a DNS-rebinding defense) only accepts the host the dashboard is bound to.
- An Origin guard. The packaged Electron renderer loads from a
file://origin, and older Hermes builds rejected non-web origins on any non-loopback bind.
Put together, a dashboard bound to 0.0.0.0 served REST fine but refused the desktop app's WebSocket. People worked around it with loopback binds plus SSH tunnels, or reverse proxies that rewrote Host to localhost and stripped Origin.
The recent fix removed all of that. On an --insecure bind, where the session token is the auth, Hermes now accepts the desktop app's file:// origin directly. A DNS-rebinding attacker on a website cannot forge a file:// origin and hold your token at the same time, so the guard was not buying anything there. The maintainers relaxed it.
Step 1: Stop your current Hermes
If you already have a dashboard running, stop it first so the new one can bind the port cleanly:
pkill -f "hermes dashboard"
If you run Hermes under launchd or systemd, stop that service instead, otherwise it will respawn the old config. Confirm nothing is still holding the port:
lsof -nP -iTCP:9119 -sTCP:LISTEN
Step 2: Persist the session token
By default Hermes mints a new random session token every time the dashboard starts. That is fine once, but every restart or reboot silently invalidates the token saved in your desktop app, and you are back to re-pasting it.
Pin it once. Add this to ~/.hermes/.env on the server:
HERMES_DASHBOARD_SESSION_TOKEN=your-long-random-token-here
Generate a strong value with openssl rand -base64 32. Hermes reads .env at startup, so from here on the token stays the same across restarts and the desktop app keeps working untouched.
๐ก Where the token lives.
~/.hermes/.envis where your API keys already live, so it is the natural home for this. Keep the file readable only by your user.
Step 3: Start the dashboard with the right flags
Now start it:
hermes dashboard --host 0.0.0.0 --port 9119 --insecure --tui --no-open --skip-build
What each flag does:
| Flag | Why it matters |
|---|---|
--host 0.0.0.0 |
Binds all interfaces so the dashboard is reachable from other machines, including your Tailscale interface. A 127.0.0.1 bind is loopback only and cannot be reached remotely. |
--port 9119 |
Default dashboard port. |
--insecure |
Uses simple session-token auth instead of the full OAuth gate. Required for token-based remote clients like the desktop app. Use only on trusted networks (see Tailscale below). |
--tui |
Critical. Turns on the embedded-chat WebSocket channels. Without it the app loops on 403. |
--no-open |
Do not pop a browser on the server. |
--skip-build |
Optional. Skips rebuilding the web UI and uses the prebuilt assets for faster startup. |
Sanity check from another machine. A working WebSocket upgrade returns 101, not 403:
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Origin: file://" \
"http://<server-ip>:9119/api/ws?token=<your-token>"
Step 4: Point the desktop app at the backend
Easiest route is the UI. Go to Settings > Gateway > Remote connection, enter the Remote URL (http://<server-ip>:9119) and the session token, and reconnect.
If you would rather skip the UI, or stop the app bootstrapping its own local Hermes install on first launch, set these before launching the app:
export HERMES_DESKTOP_REMOTE_URL="http://<server-ip>:9119"
export HERMES_DESKTOP_REMOTE_TOKEN="your-long-random-token-here"
# then launch Hermes.app / Hermes.exe
On macOS the app stores this in ~/Library/Application Support/Hermes/connection.json (equivalent app-data folder on Windows and Linux). The UI and the env vars both write there, so you rarely need to touch it by hand.
Step 5: Reach it securely over Tailscale
--insecure means anyone who can reach <server-ip>:9119 can read the token from the page and connect. Do not expose that port to the public internet. Tailscale is the clean answer. It puts both machines on a private WireGuard network, so the dashboard is reachable from your devices and nothing else.
Install Tailscale on both machines and sign into the same tailnet.
Find the server's tailnet IP (a
100.x.y.zaddress). Runtailscale ip -4on the server, ortailscale statusfrom your laptop.Use that IP as the Remote URL in the desktop app:
http://100.x.y.z:9119
Because the dashboard is bound to 0.0.0.0, it is already listening on the Tailscale interface, so there is nothing extra to configure. The Tailscale IP (or its MagicDNS name like your-server.tailXXXX.ts.net) works as-is, because a 0.0.0.0 bind accepts any Host header.
Security notes:
- The tailnet is your trust boundary.
--insecureis fine here precisely because only your own devices can reach the port. - Lock it down further with Tailscale ACLs so only the devices that need the dashboard can reach port
9119. - Do not use Tailscale Funnel (public exposure) for the raw dashboard port. If you genuinely need public browser access, run the OAuth-gated mode behind a reverse proxy instead of
--insecure.
Step 6: Make it survive reboots
You will want the dashboard to come back on its own. On macOS, a LaunchAgent at ~/Library/LaunchAgents/ai.hermes.dashboard.plist does the job (point it at your hermes binary):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>ai.hermes.dashboard</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.hermes/hermes-agent/venv/bin/hermes</string>
<string>dashboard</string>
<string>--host</string><string>0.0.0.0</string>
<string>--port</string><string>9119</string>
<string>--insecure</string>
<string>--tui</string>
<string>--no-open</string>
<string>--skip-build</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
<key>StandardOutPath</key><string>/Users/you/.hermes/logs/dashboard.log</string>
<key>StandardErrorPath</key><string>/Users/you/.hermes/logs/dashboard.error.log</string>
</dict>
</plist>
Load it with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.hermes.dashboard.plist. The token stays stable because it is pinned in ~/.hermes/.env, which the launched process reads, so no secret needs to live in the plist.
On Linux, a systemd unit does the same (or a drop-in with Environment="HERMES_DASHBOARD_TUI=1" if you start the dashboard another way).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| App loops on "reconnecting / repair", REST works | Missing --tui, or Hermes too old |
Add --tui; run hermes update |
WebSocket returns 403 with file:// origin |
Old Hermes (origin guard) | Run hermes update to a June 2026 or later build |
| "Invalid Host header" on the socket | Dashboard bound to 127.0.0.1 but reached via an IP |
Bind --host 0.0.0.0 (accepts any Host) |
| Connects, then drops after a restart | Token rotated | Pin HERMES_DASHBOARD_SESSION_TOKEN in ~/.hermes/.env |
| Auth fails out of nowhere | Stale token in the app | Re-copy the current token, or confirm the pinned one is set |
Wrapping up
It comes down to three things: --tui to turn on the chat WebSocket, --insecure so token auth (and the desktop's file:// origin) is accepted, and a pinned HERMES_DASHBOARD_SESSION_TOKEN so the connection survives restarts. Put the server on Tailscale and you have a private, always-on agent you can drive from the native app anywhere. No tunnels, no proxies, no workarounds.
