Forking Hermes Desktop into an Android PWA
I run a Hermes agent daily. The desktop app that talks to it is Electron, and I wanted the same interface on my Android phone. Building a native client was out of scope for me. Instead, we forked the desktop renderer and shipped it as a browser PWA.
Why not a native app
Hermes Desktop is one renderer with two shells: an Electron shell and a web renderer. The web renderer already speaks to the Hermes gateway over REST and a WebSocket. The Electron part exists for local files, native menus, and auto-update. None of that matters on a phone where the gateway runs the agent remotely.
A thin wrapper around the existing renderer would give us:
- the full chat UI, session list, model picker, and settings, without maintaining a second frontend;
- upstream changes merged in instead of reimplemented;
- install-to-home-screen through a standard PWA manifest.
So we stripped Electron and kept the renderer.
The fork
The repo is a monorepo. We kept only apps/desktop and apps/shared, then
removed every file that needed Node or Electron APIs. What remains is a Vite
React app that talks to the gateway only through HTTP.
Upstream moves fast, so raw merges are useless: most of their commits touch
paths we deleted. Our sync process splits their tree down to those two
directories first, then merges that split. Conflicts stay in a small,
known set of files. A dependency-drift check after each merge catches build
graph changes outside our subtree. The full procedure lives in
UPSTREAM-SYNC.md in the repo.
The bridge
window.hermesDesktop is the seam between the renderer and the world. On
desktop, Electron fills it. In the fork, src/bridge/browser-bridge.ts
fills it with web API implementations backed by gateway REST calls:
| Bridge call | Browser implementation |
|---|---|
| File reads | GET /api/fs/read-data-url, GET /api/fs/read-text |
| Uploads | POST /api/files/upload (any file), /api/chat/image-upload (images) |
| Git review | /api/git/* endpoints |
| Attachments | upload first, attach the returned host path |
Members with no browser equivalent stay undefined. Call sites feature-detect
with window.hermesDesktop?.member and fall back. This rule came from a bug:
an early stub returned empty arrays, the feature check passed, and the dead
code path ran silently. Absent means absent; no fake values.
Making the gateway same-origin
The gateway’s session cookie is host-only, and its CORS never allows
credentials. Cross-origin calls from the phone cannot authenticate. The fix
is routing, not code: nginx serves the static build and proxies /api,
/auth, /login, and /fonts to the gateway under one origin. The cookie
lands on the app’s own origin and every request just works.
The WebSocket leg needs care. The /api location must set
proxy_http_version 1.1 plus Upgrade and Connection headers. Without
them, the connection test passes but the live stream never opens.
Auth is basic-auth against the gateway: POST /auth/password-login sets the
cookie, the app polls GET /api/auth/me, and the WebSocket dials with a
single-use ticket from POST /api/auth/ws-ticket.
Mobile work the desktop never needed
The renderer assumed a mouse and a big window. Changes that mattered:
- Viewport:
100vhlies on Android. Shell roots usedvhunits and safe-area insets so the keyboard and address bar do not clip the composer. - Drawers: below a 768px breakpoint, panes collapse into edge drawers. Touch needed a real open event and a tap-outside backdrop, since hover strips do nothing on touch.
- Keyboards: hotkey badges and shortcut hints are noise on a phone. They hide behind a coarse-pointer media query. Physical keyboards still work.
- Files: Android Chrome fires no
changeevent from a detached file input. Inputs must be attached to the document beforeclick(). - Images: Android cameras default to HEIC, which no Chromium build decodes. Unsupported uploads go through a decode ladder ending in a WASM decoder and re-encode to JPEG.
- Share target: the manifest registers the installed app as an Android share target. A service worker captures the POST body (pages cannot read navigation bodies), stashes the payload in the Cache API, and redirects. The intake dialog stages a draft into the composer; it never sends by itself. You review, pick a model, then press Send.
One process rule proved itself repeatedly: headless tests pass things a phone fails. Every touch change got verified on the actual device.
Result
The phone gets the full desktop chat experience against the same gateway as the desktop app: sessions, tools, streaming, attachments, git review, and Android share-sheet integration. One codebase, upstream merges stay cheap, and there is no separate mobile stack to rot.
The service worker keeps the caching boring on purpose: hashed assets
cache-first, navigation network-first with a cached offline fallback, and it
never intercepts /api or auth paths.
Links
- hermes-mobile — the PWA fork
- Hermes Agent — the agent
and its gateway; the desktop renderer lives under
apps/desktop - Hermes documentation