# Relay Server The relay server is a lightweight Python WSS/HTTP service that enables **terminal** (remote shell), **bridge** (agent-driven phone control), media enhancements/compatibility, sessions, and voice routes in Hermes-Relay. Chat and ordinary inbound files do not require it: they use vanilla Hermes Dashboard/Gateway surfaces first. This page covers deployment and operation. Start with the [Relay API contract](./relay-api.html) for route ownership, authentication classes and build-flavor gates; the route tables below provide exact operational detail. ## Do I Need It? | Feature | Relay required? | Auth path | |---------|-----------------|-----------| | Chat | No | Dashboard/Gateway session; API bearer only for explicit Direct API | | Vanilla Hermes Voice Mode | No | Dashboard session from Manage | | Relay voice extras | Yes for relay TTS/STT/realtime endpoints; pairing optional when the API key is present | Hermes API bearer or relay session | | Realtime Agent voice engine | Yes; experimental `/voice/realtime-agent/*` broker | Hermes API bearer or relay session with `voice:realtime` | | Ordinary inbound files | No | Authenticated Dashboard file routes | | Explicit Relay tokens and phone-control screenshots | Yes | Relay session | | Terminal | Yes | Relay session | | Bridge (sideload track only) | Yes | Relay session | ## Quick Start **If you used `install.sh`** — nothing to do. Step [6/6] of the installer drops a systemd **user** unit at `~/.config/systemd/user/hermes-relay.service` and enables it. The relay is already running and will auto-restart on failure: ```bash systemctl --user status hermes-relay systemctl --user restart hermes-relay journalctl --user -u hermes-relay -f ``` Want the service to survive logging out of SSH? ```bash loginctl enable-linger $USER ``` To uninstall the relay (and the rest of the plugin) cleanly: ```bash bash ~/.hermes/hermes-relay/uninstall.sh ``` The uninstaller stops the systemd unit, removes it, daemon-reloads, and reverses every other install step (pip package, plugin symlink, skills config entry, hermes-pair shim, git clone). It's idempotent and never touches `~/.hermes/.env`, the gateway's `state.db`, or the `hermes-agent` venv core. Use `--dry-run` to preview, `--keep-clone` to keep the git tree, `--remove-secret` to also wipe the QR signing identity. **Manual run** (dev boxes, hosts without systemd-user): ```bash # Canonical entry point python -m plugin.relay --no-ssl # Legacy entrypoint still works via a thin compat shim python -m relay_server --no-ssl ``` Both entry points load `~/.hermes/.env` into the process environment **on import**, so API keys (`VOICE_TOOLS_OPENAI_KEY`, `ELEVENLABS_API_KEY`, etc.) are always present on start regardless of how the process was launched — no need to `source` anything first. This is the same pattern `hermes-gateway` uses, which is why neither unit needs an `EnvironmentFile=` directive. See [`docs/relay-server.md`](https://github.com/Codename-11/hermes-relay/blob/main/docs/relay-server.md#env-auto-loading) for the precedence rules. ### Plugin manager install Current upstream Hermes can install the plugin tree directly: ```bash hermes plugins install Codename-11/hermes-relay/plugin --enable hermes relay doctor ``` That path manages plugin code, CLI command registration, dashboard metadata, and agent tools. It does not install the systemd user service or shell shims. Use the legacy `install.sh` only when you want those host-level artifacts. ### Official Docker image On the official `nousresearch/hermes-agent` Docker image, the native install path above is the **only** supported one. The image is immutable: hermes-agent lives in `/opt/hermes/.venv` with no git clone, no user systemd, and site-packages that reset on the next `docker pull` — so `install.sh`'s editable-install/systemd path is not applicable there (the installer detects this layout and points you back to `hermes plugins install`). Run the relay inside the container with `hermes relay start` (or your own process supervisor) after installing and enabling the plugin. The optional compatibility startup hook is managed by the plugin: ```bash hermes relay compat status hermes relay compat install # older Hermes builds only hermes relay compat remove ``` Vanilla Hermes chat, Manage, and dashboard voice do not require the compat hook. New compat installs load the bootstrap implementation from the installed plugin tree. Existing legacy hooks remain visible in `hermes relay compat status` and can be removed with `hermes relay compat remove`. Pairing/setup QRs can include top-level `dashboard_url`; Android uses it for Manage and Vanilla Hermes dashboard voice instead of deriving same-host `:9119`. Current pairing also advertises the Dashboard plugin's same-origin Relay base at `/api/plugins/hermes-relay/transport`. API fields are omitted when the upstream API server is disabled; existing API-only and direct-Relay records remain valid. ### Dashboard same-origin ingress The normal Android route uses the Dashboard's existing origin for both upstream Hermes and Relay-owned capabilities. The Dashboard plugin exposes only a fixed client-safe route allowlist and forwards it to `127.0.0.1:8767`; it is not a general reverse proxy and never exposes loopback pairing/admin, bridge-control, or desktop-dispatch routes. HTTP calls must satisfy Dashboard authentication and carry the independently paired Relay session in `X-Hermes-Relay-Session`. WebSocket calls first consume a fresh Dashboard ticket and pass the Dashboard Host/Origin checks, then Relay still requires its normal first-frame pairing/session authentication and grants. Disabling the plugin closes its long-lived ingress sockets. Backend Python routes mount at Dashboard startup, so restart the Dashboard after installing or updating the plugin. ### Legacy cleanup ownership | Artifact | Remove with | |----------|-------------| | Plugin-manager install | `hermes plugins remove hermes-relay` | | Optional compat hook | `hermes relay compat remove --all` | | Legacy systemd service | `bash ~/.hermes/hermes-relay/uninstall.sh` | | Legacy shell shims | `bash ~/.hermes/hermes-relay/uninstall.sh` | | Legacy editable Python package | `bash ~/.hermes/hermes-relay/uninstall.sh` | | Legacy `skills.external_dirs` entry | `bash ~/.hermes/hermes-relay/uninstall.sh` | | Legacy clone | `bash ~/.hermes/hermes-relay/uninstall.sh` unless `--keep-clone` is used | The legacy uninstaller delegates compat hook removal to `hermes relay compat remove` when that command is available, then falls back to removing only the Relay `.pth` file. It preserves `~/.hermes/.env`, `~/.hermes/state.db`, the Hermes agent install, and the QR signing secret unless `--remove-secret` is passed. For agent-assisted cleanup, use the bounded [Agent Cleanup Prompt](/reference/agent-cleanup-prompt). ## Deployment Options ### Docker ```bash docker build -t hermes-relay relay_server/ docker run -d --name hermes-relay --network host \ -v ~/.hermes:/home/relay/.hermes:ro hermes-relay ``` Mount `~/.hermes` read-only into the container so the in-process `.env` bootstrap can find it at its canonical path. ### Systemd user service (manual install) The installer does this for you, but if you're installing from a non-standard layout: ```bash mkdir -p ~/.config/systemd/user cp relay_server/hermes-relay.service ~/.config/systemd/user/ systemctl --user daemon-reload systemctl --user enable --now hermes-relay.service loginctl enable-linger $USER # survive logout (optional) ``` The unit template uses systemd's `%h` specifier for your home directory, so no hand-editing is required on a default install (`~/.hermes/hermes-relay`, `~/.hermes/hermes-agent/venv`). ### TLS (production) ```bash export RELAY_SSL_CERT=/etc/letsencrypt/live/yourdomain/fullchain.pem export RELAY_SSL_KEY=/etc/letsencrypt/live/yourdomain/privkey.pem systemctl --user restart hermes-relay ``` Or terminate TLS at a reverse proxy (nginx/Caddy) in front of the relay. If you set these in `~/.hermes/.env`, the Python bootstrap picks them up on the next service restart — no need to edit the unit file. ## Configuration All settings via environment variables: | Variable | Default | Description | |----------|---------|-------------| | `RELAY_HOST` | `0.0.0.0` | Bind address | | `RELAY_PORT` | `8767` | Internal/direct compatibility listener. Normal Android pairing prefers the Dashboard-origin plugin ingress. | | `RELAY_SSL_CERT` | — | TLS certificate path | | `RELAY_SSL_KEY` | — | TLS private key path | | `RELAY_WEBAPI_URL` | `http://localhost:8642` | Hermes API Server URL | | `RELAY_HERMES_CONFIG` | `~/.hermes/config.yaml` | Hermes config path | | `RELAY_LOG_LEVEL` | `INFO` | Logging level | | `RELAY_SECURE_LINK_ENABLED` | `0` | Enable optional Hermes Secure Link. Restart and re-pair so the QR carries its exact origin and SPKI pin. | | `RELAY_SECURE_LINK_HOST` | `0.0.0.0` | Secure Link bind and advertised host. Reachability must come from LAN, VPN/Tailscale, or operator-managed public routing. | | `RELAY_SECURE_LINK_PORT` | `9443` | Secure Link HTTPS port. | | `RELAY_SECURE_LINK_CERT` | generated | Optional TLS certificate path; identity changes require re-pairing. | | `RELAY_SECURE_LINK_KEY` | generated | Optional TLS private-key path. | | `RELAY_SECURE_LINK_DASHBOARD_URL` | `http://127.0.0.1:9119` | Loopback Dashboard upstream for the fixed `/dashboard` namespace. | | `RELAY_EXPERIMENTAL_REACH_ENABLED` | `0` | Explicitly enable the experimental Hermes Reach connector. Not recommended for normal remote access. | | `RELAY_SECURE_LINK_BROKER_URL` | — | Experimental Reach broker `wss://` base URL. Requires Secure Link and the explicit Reach flag. | | `RELAY_SECURE_LINK_BROKER_HOST_TOKEN` | — | Raw host-registration token for the outbound Reach connector. Configure with the broker URL; never put it in a QR or broker credential file. | | `RELAY_SECURE_LINK_BROKER_HOST_ID_FILE` | `~/.hermes/relay-secure-link/host-id` | Persistent opaque Reach host-ID file. | | `RELAY_TRUST_PROXY_HEADERS` | `0` | Trust `X-Forwarded-Proto: https` from your own reverse proxy for Hermes API bearer auth on `/voice/*`. | | `RELAY_ALLOW_INSECURE_API_BEARER` | `0` | Dev-only escape hatch for non-loopback plaintext Hermes API bearer auth on `/voice/*`. Leave off for production. For a running relay, use `hermes relay insecure-api-key on` or the standalone `hermes-relay insecure-api-key on` shim instead of restarting with env. | | `RELAY_PROVIDER_USAGE_ENABLED` | `0` | Opt in to the bearer-authenticated `/usage/providers` compatibility endpoint. This exposes account quota/balance metadata to paired devices but never provider credentials. | | `RELAY_MEDIA_MAX_SIZE_MB` | `100` | Per-file size cap for `POST /media/register` (inbound media pipeline — see ADR 14) | | `RELAY_MEDIA_TTL_SECONDS` | `86400` | How long a registered media entry stays fetchable | | `RELAY_MEDIA_LRU_CAP` | `500` | Max entries in the in-memory media registry before LRU eviction | | `RELAY_MEDIA_ALLOWED_ROOTS` | — | Extra absolute-path roots allowed on register (`os.pathsep`-separated). Extends defaults (`tempfile.gettempdir()` + `HERMES_WORKSPACE`). | Hermes Secure Link uses one pinned HTTPS origin with fixed `/relay`, `/api`, and `/dashboard` namespaces. Those routes share transport trust, not credentials: Relay sessions, API bearers, and Dashboard cookies/native bearers remain separate. The pairing QR establishes the endpoint authority and pin before the first request; it verifies continuity with that paired endpoint, not the physical host's identity. Secure Link is opt-in and does not create network reachability. After enabling it, check `secure_link.status` in the ordinary Relay `/health` response and re-pair. See [Remote access](/guide/remote-access). Hermes Reach is a separate, experimental outbound rendezvous service, run as `python -m plugin.rendezvous --credentials --listen 0.0.0.0 --port 9444 --tls-cert --tls-key `. Its public surface is `GET /health` and WSS `/v1/connect`. It forwards opaque records while QR-pinned Secure Link TLS protects the inner session end-to-end. It is not required for direct, Tailscale, or Secure Link routes. Configure the connector through `RELAY_EXPERIMENTAL_REACH_ENABLED=1`, `RELAY_SECURE_LINK_BROKER_URL`, and `RELAY_SECURE_LINK_BROKER_HOST_TOKEN`; both are required, and Secure Link must already be enabled. Relay `/health` reports `secure_link.reach` as `disabled`, `unavailable`, `stopped`, `connecting`, `connected`, or `backoff` with bounded stream, attempt, connection-time, and error diagnostics. Reach is attempted only after supported routes are unavailable. The broker credential file stores only base64url SHA-256 host-token digests under opaque host IDs; public listeners require TLS, while plaintext development mode is loopback-only. Voice endpoints accept either a Relay session token or the existing Hermes API bearer token. The Hermes API bearer path is limited to `/voice/config`, `/voice/transcribe`, `/voice/synthesize`, `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*`; pairing is still required for terminal, bridge, TUI, sessions, media, clipboard, profile writes, and Android control routes. Current Android pairing prefers the Dashboard-origin plugin ingress and retains direct `:8767` derivation only for legacy/advanced compatibility. Custom Relay URLs remain supported. Non-loopback API-bearer voice calls require HTTPS by default. For temporary plain-LAN phone testing, run `hermes relay insecure-api-key on` or `hermes-relay insecure-api-key on` on the relay host; disable it with the matching `off` command. ## CLI Flags ``` hermes relay start [OPTIONS] (or: python -m plugin.relay) --host HOST Bind address (default: 0.0.0.0) --port PORT Listen port (default: 8767) --no-ssl Disable TLS (dev/localhost only) --shell PATH Default shell for terminal sessions (absolute path; default: $SHELL) --webapi-url URL Hermes WebAPI base URL (default: http://localhost:8642) --log-level LEVEL DEBUG, INFO, WARNING, ERROR --config PATH Hermes config.yaml path (python -m plugin.relay only) --allow-insecure-api-key Allow API-key voice auth over plain LAN HTTP at startup hermes relay insecure-api-key [status|on|off] hermes-relay insecure-api-key [status|on|off] Toggle plain-LAN API-key voice auth on the running relay hermes relay compat [status|install|remove] Manage the optional legacy API compatibility startup hook ``` ## HTTP Routes | Route | Method | Purpose | |-------|--------|---------| | `/ws`, `/` | GET (upgrade) | WebSocket endpoint — phone connects here | | `/health` | GET | `{status, version, clients, sessions}` JSON | | `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach host-authorized pairing metadata that applies to the session when the phone consumes the code; when omitted, the relay uses bounded server defaults rather than client-supplied policy. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `hermes pair` / `/hermes-relay-pair` on the same host; `hermes-pair` remains a compatibility shim. Rejects non-loopback peers with HTTP 403. | | `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and CLI/tray pair/repair flows. Reads the API key from the same host-local config chain as `hermes pair` when not supplied explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. | | `/pairing/approve` | POST | **Loopback only, reserved for future use.** Same wire shape as `/pairing/register`. Placeholder for a future phone-generates-code / host-approves flow that would complement the existing QR pairing direction. | | `/sessions` | GET | Bearer-auth'd (same token the WSS channel uses). Returns all active paired devices with metadata — device name, token prefix (first 8 chars, full token never exposed), created/last-seen timestamps, session expiry, per-channel grants, transport hint, and `is_current` for the device matching the bearer. `math.inf` expiries serialize as `null` (never expire). | | `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by token-prefix (≥ 4 chars). 200 on exact match, 404 on zero, 409 on ambiguous matches. Self-revoke is allowed and flagged via `revoked_self: true`. | | `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{ttl_seconds?, grants?}` may shorten the caller's current lifetime or existing grants. Extending policy, adding grants, switching to never-expire, or changing another session requires a fresh operator-approved pairing flow. | | `/api/plugins/hermes-relay/provider-usage` | GET | Dashboard-authenticated provider usage for Codex, Nous, OpenCode Go, and the Grok subscription behind `xai-oauth`. Optional `profile=` and `session_id=` scope the lookup and let the Dashboard plugin identify the active Codex pool credential directly from the live session. | | `/usage/providers` | GET | Bearer-authenticated standalone Relay fallback for the same provider-neutral data. Optional `profile=` and `session_id=` scope credentials and active-session correlation. Disabled by default; set `RELAY_PROVIDER_USAGE_ENABLED=1`. Never returns provider credentials. | | `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. | | `/media/register` | POST | **Loopback only.** Register a host-local file with the `MediaRegistry` and receive an opaque token. Body: `{"path": "/abs/path", "content_type": "image/png", "file_name": "screenshot.png"}`. The optional `owned_file: true` is restricted to recognized temporary upload/screenshot files, which Relay removes when the last token retires. Other registered files remain caller-owned. Paths are sandboxed by real path and size. | | `/media/upload` | POST | Bearer-authenticated multipart upload for phone-originated media (`file` field). Relay streams it to a size-bounded temporary file, then removes that file on token expiry, eviction, or orderly shutdown. | | `/media/{token}/sensitive` | POST | **Loopback only.** Mark an existing token sensitive before returning it to the phone; later fetches include `X-Media-Sensitive: 1` without another image copy. | | `/media/{token}` | GET | Stream the bytes of a previously-registered file. Requires `Authorization: Bearer ` — same token the WSS channel uses (validated against `SessionManager`). Response carries the registered `Content-Type` plus `Content-Disposition: inline; filename="..."` if a file name was provided at register time. The client only ever sees the opaque token — the path is never exposed. 401 without/with bad auth, 404 for unknown/expired tokens. | | `/media/by-path` | GET | Bearer-authenticated absolute-path fetch for a `MEDIA:/...` marker. Regular-file, size, and credential/system-path checks always apply. Allowed-root enforcement is opt-in through `RELAY_MEDIA_STRICT_SANDBOX=1`; default mode accepts other absolute readable paths. Optional `content_type` overrides extension guessing. Returns 400 for missing path, 401 for auth, 403 for policy/size, and 404 for a missing file. | | `/voice/transcribe` | POST | Bearer-auth'd via either a Relay session token with active `voice:stt` grant or a valid Hermes API bearer token. Non-loopback API-bearer calls require HTTPS unless `RELAY_ALLOW_INSECURE_API_BEARER=1`. `multipart/form-data` with an audio file → `{"text": "...", "provider": "openai", "success": true}`. Android may include `?profile=` so logs/UI retain the active profile context; execution still goes through the upstream STT helper. See [Voice Mode](/features/voice) for the full story. | | `/voice/synthesize` | POST | Bearer-auth'd via either a Relay session token with active `voice:tts` grant or a valid Hermes API bearer token. Non-loopback API-bearer calls require HTTPS unless `RELAY_ALLOW_INSECURE_API_BEARER=1`. JSON body `{"text": "...", "profile": "mizu"}` (max 5000 chars) → `audio/mpeg` file. This is the basic fallback TTS route; normal Android voice playback prefers `/voice/output/*`. | | `/voice/config` | GET | Bearer-auth'd via either a Relay session token with active `voice:config` grant or a valid Hermes API bearer token. Non-loopback API-bearer calls require HTTPS unless `RELAY_ALLOW_INSECURE_API_BEARER=1`. Optional `?profile=` resolves `tts:` / `stt:` from `~/.hermes/profiles//config.yaml` when present, otherwise falls back to global config. Returns `config_scope`, `profile`, and `fallback_to_global` so the app can label Voice Settings accurately. | | `/voice/output/config` | GET/PATCH | Relay-mediated streaming TTS renderer settings. Optional `?profile=` reads or writes the experimental `voice_output:` section in that profile's `config.yaml`; absent profile writes relay-owned defaults. Responses include provider/model/voice, `config_scope`, fallback metadata, and known provider option lists (`models`, `voices`, `languages`, `sample_rates`) for app dropdowns. | | `/voice/output/providers/{provider_id}/options` | GET | Provider-specific Voice Output option refresh. Optional `?profile=` returns profile/scope metadata alongside the provider option object. Dynamic provider discovery stays server-side and cached: xAI can refresh built-in/paginated custom voices, ElevenLabs can refresh voices/models/languages, OpenAI uses static documented voices, and all providers fall back to static metadata when auth or remote discovery is unavailable. Responses include grouped voice metadata and compatibility hints when available. | | `/voice/output/providers/{provider_id}/validate` | POST | Validates a pending Voice Output selection before save. Body accepts `model`, `voice`, `sample_rate`, optional `language`, and optional `profile`; response returns `valid`, `checks[]`, and `summary`. | | `/voice/output/session` | POST | Creates a short-lived streaming TTS websocket session using profile-scoped voice output defaults when `{"profile": ""}` is supplied. Hermes still owns chat/tool execution; this route renders final assistant text to audio. | | `/voice/realtime/config` | GET/PATCH | Experimental realtime provider lab settings. Optional `?profile=` reads or writes `realtime_voice:` in the selected profile config; absent profile uses relay-owned defaults. Responses include known provider option lists for app dropdowns while still allowing manual IDs. | | `/voice/realtime/providers/{provider_id}/options` | GET | Provider-specific Realtime Agent Lab option refresh for realtime-capable providers. Optional `?profile=` returns profile/scope metadata. xAI realtime shares the same built-in/paginated custom voice discovery path as xAI TTS; OpenAI realtime uses static documented voices. | | `/voice/realtime/providers/{provider_id}/validate` | POST | Validates a pending realtime provider/model/voice/sample-rate selection before save. | | `/voice/realtime/session` | POST | Creates an experimental realtime voice websocket session with profile-scoped defaults when `{"profile": ""}` is supplied. Used for provider testing, speech-to-speech experiments, and tool-call scaffolding rather than the default deterministic assistant speech renderer. | | `/voice/realtime-agent/config` | GET/PATCH | Experimental Realtime Agent voice engine config. Uses profile-scoped `realtime_voice:` settings but reports the broker mode, stable fallback engine, Experimental status, limits, and narrow Hermes tool surface. | | `/voice/realtime-agent/providers/{provider_id}/options` | GET | Provider-specific option refresh for the Realtime Agent settings UI. Returns safe model/voice/sample-rate metadata and keeps provider account discovery server-side. | | `/voice/realtime-agent/providers/{provider_id}/validate` | POST | Validates a pending Realtime Agent provider/model/voice/sample-rate selection before save. | | `/voice/realtime-agent/session` | POST | Creates a brokered Realtime Agent websocket session bound to active profile, optional Hermes chat session id, provider defaults, auth principal, and event log path. | | `/voice/realtime-agent/{session_id}` | GET websocket | Experimental broker websocket. It mirrors input transcript, Hermes session/tool/confirmation state, final response text, and provider PCM into the app timeline while Hermes remains the owner of tools, memory, safety, confirmations, and transcript persistence. | | `/notifications/recent` | GET | Loopback callers skip bearer auth; remote callers need it. Returns the most recent entries from the bounded in-memory `NotificationsChannel` deque (default cap 100, wiped on relay restart). Backs the `android_notifications_recent(limit=20)` plugin tool. | | `/bridge/status` | GET | **Loopback only.** Structured `{"device": {...}, "bridge": {...}, "safety": {...}}` phone-status view used by `android_phone_status()`, the `hermes-status` shell shim, and the `/hermes-relay-status` skill. | | `/relay/security` | GET/PATCH | **Loopback only.** Runtime security toggles used by `hermes relay insecure-api-key` and `hermes-relay insecure-api-key`. `GET` reports the running relay's `allow_insecure_api_bearer` state; `PATCH {"allow_insecure_api_bearer": true}` enables plain-LAN API-key voice auth until the relay restarts. | ## Bridge HTTP Routes The bridge channel has two scopes: - **Bridge Core** is available on Google Play and sideload builds. It covers relay pairing/status plus non-device-control channels such as terminal/TUI relay, voice, explicit Relay media enhancements, sessions, and notification companion. Standard inbound files remain Dashboard-owned. - **Device Control** is sideload-only. It publishes the HTTP surface below for Hermes `android_*` plugin tools. Every route is proxied over the phone's WSS connection to the in-app `BridgeCommandHandler` and runs through the safety pipeline (blocklist → destructive-verb confirmation → auto-disable reschedule) before any gesture fires. As of v0.4 the Device Control surface is **34 routes** (33 excluding the legacy `/apps` alias) covering gestures, accessibility-tree reads, clipboard, media control, raw intents, an event stream, a phone-utility tier (send_sms, call, search_contacts, location, share_media, send_mms), and a self-foreground route (`/return_to_hermes`). Google Play Bridge Core phones report `bridge.device_control_supported=false`; the plugin hides the `android_*` Device Control tools, and direct command probes fail closed with `403` / `error_code: device_control_sideload_only`. | Route | Method | Group | Purpose | |-------|--------|-------|---------| | `/ping` | GET | core | Liveness — bypasses the master-enable gate | | `/setup` | POST | core | One-shot welcome ping the agent can send before issuing real commands | | `/current_app` | GET | core | Best-effort foregrounded package name (bypasses master-enable gate). Accessibility/SystemUI state can lag; use `/screen` for verification. | | `/screen` | GET | read | Full accessibility tree → `ScreenContent(rootBounds, nodes[], truncated)`. Walks every accessibility window (system UI, popups, notification shade), not just the active app. | | `/screen_hash` | GET | read | SHA-256 fingerprint of the current screen for cheap change detection | | `/diff_screen` | POST | read | Compare current screen against a previous hash without re-downloading the full tree | | `/find_nodes` | POST | read | Filtered accessibility-tree search (clickable, text match, resource-id, class, etc.) | | `/describe_node` | POST | read | Full property bag for a stable node ID previously returned by `/screen` / `/find_nodes` | | `/screenshot` | GET | read | PNG bytes via `MediaProjection` → `VirtualDisplay` → `ImageReader`, uploaded to the relay's media registry and returned as an opaque token | | `/get_apps` | GET | read | Launchable app list from `PackageManager.queryIntentActivities(ACTION_MAIN + CATEGORY_LAUNCHER)`. Requires a matching `` element in the manifest on Android 11+. | | `/apps` | GET | read | Legacy alias for `/get_apps` (pre-v0.4 tool name) | | `/tap` | POST | act | Coordinate or `nodeId`-based tap. Runs the destructive-verb gate when a text hint is attached. | | `/tap_text` | POST | act | Three-tier tapText cascade — exact match → clickable-ancestor walk → substring fallback. Destructive-verb gated. | | `/long_press` | POST | act | Long-press gesture (context menus, drag initiation, widget rearranging) — by coordinate or node ID | | `/drag` | POST | act | Drag gesture — point A → point B over a duration | | `/type` | POST | act | Set text on the focused input via `ACTION_SET_TEXT`. Destructive-verb gated. | | `/swipe` | POST | act | Directional swipe gesture | | `/scroll` | POST | act | Scroll a container by direction or node ID | | `/press_key` | POST | act | Global action vocab — `back`, `home`, `recents`, `notifications`, etc. No raw KeyEvent injection. | | `/wait` | POST | act | Sleep in the command stream (capped at 15 s) | | `/open_app` | POST | act | Launch a package by ID; safety-rails blocklist runs against the *target* package, not just the current foregrounded app | | `/clipboard` | GET/POST | act | Read or write the system clipboard | | `/media` | POST | act | System-wide playback control — play / pause / next / previous / volume | | `/send_intent` | POST | act | Raw Android Intent escape hatch (startActivity) | | `/broadcast` | POST | act | Raw Android broadcast escape hatch (sendBroadcast) | | `/events` | GET | events | Poll the recent AccessibilityEvent buffer (structured UI events — window state changes, view clicks, scrolls, content changes) | | `/events/stream` | POST | events | Toggle accessibility-event capture on / off on the phone side | | `/location` | GET | sideload-only | GPS last-known-location read | | `/search_contacts` | POST | sideload-only | Contact lookup by name → phone number | | `/call` | POST | sideload-only | Place a call via `ACTION_CALL` | | `/send_sms` | POST | sideload-only | Direct text-only SMS send via `SmsManager` with structured `sent`, `blocked`, `timeout`, or `failed` status details | | `/share_media` | POST | sideload-only | Share text/files/relay media through Android's native share UI using `FileProvider` `content://` grants | | `/send_mms` | POST | sideload-only | Open a user-mediated MMS compose/share handoff with recipient, text, and attachments | **Gating.** Device Control routes require a sideload phone reporting `bridge.device_control_supported=true`. On the Google Play build, `/ping`, `/events`, and `/setup` can answer harmless probes, while Device Control commands fail closed before any AccessibilityService-dependent code runs. On sideload, every route except `/ping`, `/current_app`, and `/return_to_hermes` is refused with 403 when the in-app master toggle is off. Blocklisted target packages return 403 `{"error": "blocked package "}`; denied destructive-verb confirmations return 403 `{"error": "user denied destructive action", "reason": "confirmation_denied_or_timeout"}`. ## Pairing Model The phone does **not** enter a pairing code by hand. Instead, the pair command (`hermes pair`, the `/hermes-relay-pair` slash command, or the compatibility `hermes-pair` shell shim, all running on the Hermes host) drives the whole handshake: 1. The pair command mints a fresh 6-character code from `A-Z / 0-9` 2. It POSTs the code to `/pairing/register` on the local relay (blocked for any caller outside `127.0.0.1` / `::1`) 3. It embeds the relay URL and code in the same QR payload that carries the API server credentials 4. The phone scans once — the relay block auto-configures Settings > Connection 5. The phone's first WSS connect uses that code in its `system/auth` envelope; the relay consumes it and issues a 30-day session token Pairing codes are one-shot and expire 10 minutes after registration. Session tokens (stored in EncryptedSharedPreferences on device) are used for all subsequent reconnects. ## Health Check ```bash curl http://localhost:8767/health ``` ## Troubleshooting - **Connection refused** — Is the relay running? `systemctl --user status hermes-relay` (installed via `install.sh`) or `docker logs hermes-relay` (container) or `pgrep -af "python -m plugin.relay"` (manual launch). - **Unsure what is installed** — Run `hermes relay doctor --json` for a full route/plugin/compat report, or `hermes relay compat status` for only the legacy hook. - **Voice endpoints 500 with "no API key available"** — The relay process doesn't have the right keys. The Python bootstrap loads `~/.hermes/.env` automatically, so this almost always means the key just isn't in `.env` yet. Double-check with `grep VOICE_TOOLS_OPENAI_KEY ~/.hermes/.env` (for STT) or `grep ELEVENLABS_API_KEY ~/.hermes/.env` (for TTS). If you just edited `.env`, restart the service so Python re-imports: `systemctl --user restart hermes-relay`. - **Service starts but port bind fails** — Check for an orphan manual launch: `pgrep -f "python -m plugin.relay"`. Kill it with `pkill -f "python -m plugin.relay"` then `systemctl --user restart hermes-relay`. - **Auth failure** — Pairing codes expire 10 minutes after registration and are one-shot. Re-run `hermes pair` (or `/hermes-relay-pair`) to mint a fresh code and get a new QR. - **QR has no relay block** — the pair command only embeds relay details if it can reach `localhost:RELAY_PORT/health` when it runs. Start the relay first, then re-run `hermes pair`. - **TLS errors** — Use `--no-ssl` for local dev. Ensure cert paths are correct for production. - **Phone can't reach relay** — Check firewall rules for port 8767. Verify with `curl http://server-ip:8767/health` from another machine. - **Remote chat or API-key voice fails but relay pairs** — Verify the Hermes API route too: `curl http://server-ip:8642/health`, or `https://.ts.net:8642/health` when using Tailscale. Pairing can succeed through `:8767` while chat and API-key voice fail if `:8642` is not published. - **Service stops when you log out of SSH** — That's systemd's default for user services. Run `loginctl enable-linger $USER` once to fix it. ## Further Reading - [Full relay server docs](https://github.com/Codename-11/hermes-relay/blob/main/docs/relay-server.md) - [Architecture decisions](https://github.com/Codename-11/hermes-relay/blob/main/docs/decisions.md) - [Specification](https://github.com/Codename-11/hermes-relay/blob/main/docs/spec.md)