One process, in front
of one gateway.
Hermie Web serves the browser build of Hermie and proxies one Hermes gateway onto its own origin. It has no runtime dependencies, needs Node 22 or newer, and authenticates nobody — the gateway does that, and Hermie Web makes the gateway’s session reachable from a page.
Same origin is the point rather than a convenience. The gateway’s session is an HttpOnly cookie, and the gateway refuses a WebSocket whose origin it does not recognise, which is a defence against a web page on another site dialling your gateway through your browser. Putting both behind one address is what lets the gateway’s own sign-in work in a tab.
- What it is
- A Node server you run yourself, beside
hermes serve - Package
@hermie/web, installed ashermie-web- Default port
9120, bound to127.0.0.1- Licence
- MIT, in the app repository
Everything under /api, /auth, /login and /logout goes to the gateway; everything else is the app. A browser has no keychain and a page cannot put extra headers on a WebSocket, so an access proxy belongs in front of Hermie Web rather than configured inside it.
Next to your gateway.
On the machine that already runs hermes serve:
npx @hermie/web --gateway http://127.0.0.1:9119It answers on http://127.0.0.1:9120. It binds to loopback unless you tell it otherwise, because anything else puts an unauthenticated port on a network. Put TLS in front of it if it has to leave the machine.
Two settings on the gateway
dashboard.public_urlAlways. It is the host the gateway believes it is served on, and Hermie Web writes it into every proxied request — which is what gets past the gateway’s rebinding guard.
dashboard.trusted_proxiesOnly when Hermie Web runs on a different machine, so the gateway reads the forwarded address instead of seeing Hermie Web as every client.
/api/status, or a WebSocket that refuses the upgrade. That is the guard doing its job: say the public address explicitly with --public-url when it is not what the gateway address implies.Settings → Hermie Web in the app shows the running version, whether a newer release exists and an Update button where the install shape allows it. The download is checked against the release’s checksums — that is a digest, not a signature. A global npm install and a container both answer that they cannot update themselves, and say so instead of offering the button.
The deployment runbook has the release-zip install, a systemd unit, Docker, and TLS with Caddy, nginx or Tailscale Serve.
An operator’s job,
done once.
Start it with no --gateway and it serves an operator setup page at /setup instead of the app: the gateway address, a probe of it, and the service login that push and the message cache are spent on — the same sign-in the command line does, run in a browser for a machine with no shell open.
npx @hermie/webSaving writes it to the state directory and /setup answers 404 from then on, for this process and for every later one. The next person to open the address sees a sign-in and nothing else.
- Do not expose the port before it is configured
While that page is open, whoever reaches the port can point the proxy somewhere. It is the same power the command-line flag gives, handed to the first arrival — and passing
--gatewayskips the page entirely. - Open it on the loopback address, not
localhostThe service login is the gateway’s native sign-in flow, and the gateway refuses a redirect that does not come back to a loopback IP literal. Where that is impossible, sign in from a terminal with
hermie-web logininstead. - Restart once you have saved
The push daemon is not started on a process that had no gateway when it booted.
What a reader gets afterwards is the point of all this. The browser build asks the server which gateway is behind the proxy and what signing in to it takes, so the wizard has no address step, no probe to wait for and no welcome cover — it opens on the sign-in. “Change gateway” stays hidden, because on the web the gateway is not the reader’s to change.
A chat that is already there.
Hermie Web keeps a copy of each chat’s tail in its own state directory, filled from the gateway connection it already holds for notifications and from the transcript reads it already passes along. The app asks for that copy before it dials, so a chat opened on a laptop, a borrowed machine or a phone that has never seen it draws immediately and stays still while the rest loads.
Nothing is lost when the copy is not there: the chat opens the way it always did.
--cache-max-mb 0 turns the whole thing off.A shared Bot Chat is cached for everybody, because that conversation is shared anyway. A chat of your own is cached under the reader the gateway names and comes back to that reader alone — to anybody else it simply is not there, because a refusal would also say the chat exists. On a service started without push there is no gateway connection to tell the two apart, so the careful reading is taken and every captured copy belongs to whoever fetched it.
What this service does,
and for whom.
/admin on a configured service: what it is running, how much of the cache is used, whether an update is waiting, which notification types it will send at all, whether a notification may carry message text, how long a cached chat is kept, and an update button. There is a branding panel too — a name, an accent and a starting theme, read by the app before it draws anything, as a starting point and never an override — and switches that turn private chats, the cache or the update button off for everybody on this deployment.
It also lists the people who have signed in through it, with what this service will do for each of them: which bots they may reach, whether their notifications go out, and whether it will pass their changes on to the gateway.
Whoever finishes setup becomes the first administrator; others are added by their gateway account id. On a gateway with no accounts there is nobody to recognise, so setup can take an administrator secret instead — stored as a hash, never shown back. The list is who has signed in through this service rather than the gateway’s account list, and the page labels which of the two it is showing.
Every page it serves is plain HTML with no scripts in it, so it works on the day something is wrong.
And when there is nothing
to sign them in with.
A gateway that knows who people are needs an identity provider. For one gateway shared by a handful of people, that is a second service with its own database in front of a single process — so Hermie Web can be that provider itself. It is off unless you turn it on, and a deployment that already has one should leave it that way.
It refuses to switch on without TLS, because the gateway would refuse such an issuer anyway. Turning it on changes nothing on the gateway by itself — the page prints the exact lines to put in the gateway’s configuration, with your own issuer and client id already filled in.
Accounts are added by sending somebody a one-time link they choose their own password with, so nobody ever has to send a password to anybody. There is optional two-factor with an authenticator app and recovery codes for the day a phone is lost, and a Test sign-in that runs the whole round trip from the server and tells you which step failed rather than leaving you to read a log.
Authorization code with PKCE and nothing else: no implicit flow, no password grant, no dynamic client registration. The reasoning and the whole threat model are written down in the project’s architecture records rather than summarised here.
Read about app privacy