# Setup — Pairing a Device User-facing guide: get a phone or desktop talking to your hermes gateway in under 10 minutes. Design rationale lives in the numbered docs ([`09-pairing-security.md`](09-pairing-security.md), [`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is just the steps. ## Prerequisites | Where | You need | |---|---| | Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) | | Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device | | Desktop build machine | JDK 17 only | Gradle needs no system install — both apps use the project wrapper (`./gradlew`). First-time machine setup: [`12-toolchain.md`](12-toolchain.md). ## 1. Gateway setup (on the gateway host) Install the plugin into the live hermes home (dev: a symlink from the monorepo root): ```bash mkdir -p ~/.hermes/plugins ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android hermes gateway status # should list "android" ``` Run the interactive setup: ```bash hermes gateway setup ``` What it does: - Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in `~/.hermes/.env` (it prints the token once, at generation). - Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`), and push backend (`fcm` or `ntfy`, default `fcm`). - Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…` string) and the server URL (`ws://:8790/ws`). Then start the gateway: ```bash hermes gateway # or: hermes gateway restart after config changes ``` > **Note:** the default bind host `127.0.0.1` only accepts connections from the > gateway host itself (e.g. a desktop app on the same machine). For a phone on > the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set > `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`). ## 2. Android app Build and install (ADB device connected): ```bash cd app ./gradlew :androidApp:installDebug ``` First run opens the **Connect** screen: 1. **Server URL** — `ws://:8790/ws` (the URL printed by `hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone). 2. **Pairing token** — from the `hermes gateway setup` output, or `grep ANDROID_TOKEN ~/.hermes/.env` on the gateway host. 3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the pairing and connects. > **Honest limitation:** QR scanning is **not** supported in the app yet. The > server prints a QR payload, but pairing is manual URL + token entry only. ## 3. Desktop app ```bash cd app ./gradlew :desktopApp:run # dev run ./gradlew :desktopApp:jpackage # native app-image (bundles the JRE) ``` Pairing is the same Connect screen (URL + token); the token is stored in the OS keyring (with an encrypted-file fallback). Desktop push is tray icon + OS notifications (no FCM). > **Known issue:** on Linux with JDK 17 the jpackage launcher prints a > non-fatal `pure virtual method called` warning (JDK-8348560, a > jpackage/Linux launcher bug). The app runs and connects regardless. ## 4. Push notifications Push wakes a backgrounded/offline device; on reconnect the app syncs the outbox, so nothing is lost. Push fires when the device is offline, plus for high-priority events (approvals, clarifies, cron) even when a device is live. ### FCM (default; needs a Firebase project) 1. Create a Firebase project (console.firebase.google.com) and add an Android app with the app's applicationId; download `google-services.json` into `app/androidApp/`. 2. Create a service account (Project settings → Service accounts → Generate new private key) and store the JSON path in `~/.hermes/.env`: `ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`. 3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default). Without a Firebase project the FCM path is **inert** (the app's FCM service does nothing) — use ntfy below, or add Firebase later. **What you see:** system notifications for new messages when the app is backgrounded; tapping one deep-links to the chat. ### ntfy (zero-config fallback) ``` ANDROID_PUSH_BACKEND=ntfy ``` - The device **generates its own topic** automatically (no `NTFY_TOPIC` needed); the server publishes to it. - `NTFY_SERVER_URL` defaults to `https://ntfy.sh`. **Self-hosted ntfy is recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its web UI instead of the stream), while a self-hosted instance gives reliable SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`. **What you see:** a low-priority foreground "ntfy listener" notification while the app is off; incoming pushes trigger a silent sync. ## 5. Remote access - **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP; the app connects to `ws://:8790/ws`. No public exposure. - **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at the edge, forward the WebSocket to `127.0.0.1:8790`. - **WSS:** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in `~/.hermes/.env`) and the server serves `wss://` instead of `ws://`. > **Honest limitation:** the app has **no certificate pinning** yet, so > self-signed certs won't work — remote access requires **CA-signed** WSS for > now. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the default. ## 6. Troubleshooting | Symptom | Likely cause / fix | |---|---| | `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `ANDROID_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). | | Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. | | Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. | | Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. | Smoke test without the app (from the gateway host): ```bash python - <<'PY' import asyncio, json, websockets async def main(): async with websockets.connect("ws://127.0.0.1:8790/ws") as ws: await ws.send(json.dumps({"v":1,"type":"hello","payload":{ "token":"","device_id":"test","device_name":"probe", "caps":{"min_protocol":1}}})) print("recv:", await ws.recv()) asyncio.run(main()) PY ``` Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is wrong.