01 / WHAT IT IS

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 as hermie-web
Default port
9120, bound to 127.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.

02 / RUN IT

Next to your gateway.

On the machine that already runs hermes serve:

On your gateway machine
npx @hermie/web --gateway http://127.0.0.1:9119

It 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_url

Always. 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_proxies

Only when Hermie Web runs on a different machine, so the gateway reads the forwarded address instead of seeing Hermie Web as every client.

A mismatch between the two shows up as a 403 on /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.

03 / SET IT UP ONCE

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.

First run, with nothing configured
npx @hermie/web

Saving 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.

  1. 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 --gateway skips the page entirely.

  2. Open it on the loopback address, not localhost

    The 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 login instead.

  3. 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.

04 / THE MESSAGE CACHE

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.

This is transcript content on your disk, not only a credential. The state directory is private to the user the service runs as, the cache is capped at 64 MB by default with the least recently opened chat the first to go, and --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.

05 / THE ADMIN PAGE

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.

Those are this service’s settings, not the gateway’s. Notifications and the cached chats are its own and are enforced completely; “read-only” stops every change that arrives over HTTP and cannot stop somebody typing into a chat, because the gateway connection is a byte pipe this service deliberately does not read. It is a guard rail against accident, not a boundary against intent — and the page says so where you set it.

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.

06 / SIGNING PEOPLE IN

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.

Turning it on makes this service the thing your gateway trusts to say who people are: whoever can read its state directory can be anybody on that gateway. There is no setting that softens that, which is why the switch is on a page of its own with the consequence written beside it.

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