# 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/iris hermes gateway status # should list "iris" ``` Run the interactive setup: ```bash hermes gateway setup ``` What it does: - Generates `IRIS_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 (`ntfy` or `fcm`, default `ntfy`); warns when `fcm` is chosen (push metadata via Google's servers). - Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…` string), a scannable QR of that payload, 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 > `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`). ## 2. Iris app (Android) 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 IRIS_TOKEN ~/.hermes/.env` on the gateway host. 3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the pairing and connects. > **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX + > ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the > URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair` > deep link (from any scanner) pre-fills the same way. ## 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. ### ntfy (default; zero-config) ``` IRIS_PUSH_BACKEND=ntfy # the default — can be left unset ``` - 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`. - **Privacy:** ntfy keeps push metadata (title, topic) on your own infrastructure — this is the backend for truly private communication. **What you see:** a low-priority foreground "ntfy listener" notification while the app is off; incoming pushes trigger a silent sync. ### FCM (opt-in; needs a Firebase project) > **Privacy note:** FCM push metadata (notification title, device token) is > routed through **Google's servers**. If you want truly private > communication, use ntfy (self-hosted) instead — it is the default. 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`: `IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`. 3. Set `IRIS_PUSH_BACKEND=fcm`. Without a Firebase project the FCM path is **inert** (the app's FCM service does nothing) — use ntfy (the default), or add Firebase later. ## 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 `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in `~/.hermes/.env`) and the server serves `wss://` instead of `ws://`. > **Self-signed certs:** the app has a fingerprint-confirm flow (docs/09 > §9.4): on first pair it shows the gateway cert's SHA-256 fingerprint; once > you confirm it, the cert is pinned in secure storage (like an SSH host > key). The cert needs a SAN for the URL host. CA-signed certs work out of > the box. 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 `IRIS_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.