A thin, cross-platform browser UI for the Hermes gateway - the official Hermes desktop renderer, repackaged as a plain Vite web app.
View Demo
·
Report Bug
·
Request Feature
Table of Contents
hermes-ui is a thin browser UI wrapper over the Hermes gateway.
It is the official Hermes desktop renderer, extracted from the hermes-agent monorepo and repackaged as a plain Vite web app, so the same UI can be served from a browser instead of Electron.
It ships no backend of its own: every request - REST, auth, and the WebSocket - goes to a running Hermes gateway. This keeps the app tiny, portable, and easy to serve from the gateway itself, from a dev proxy, or from any reverse proxy you already run.
Highlights
- Runs in any modern browser, with a PWA layer for install-to-home-screen.
- Zero-config same-origin serving when hosted directly from the gateway.
- Runtime gateway switching - save personal and company gateways and switch between them, all in the browser.
- Token and OAuth authentication support.
For the extraction plan and provenance, see:
- PLAN.md - the plan behind this repo.
- UPSTREAM.md - upstream commit, what was changed, and how to re-sync.
A short walkthrough of the UI running against a Hermes gateway - login, chat, the model picker, themes, and settings.
hermes-ui-demo.mp4
- React 19 + TypeScript - the UI layer.
- Vite - build tooling and dev server, with
vite-plugin-pwafor the PWA layer. - Tailwind CSS 4 - styling.
- TanStack Query - server-state and caching.
- assistant-ui - chat and streaming primitives.
- xterm.js - the in-app terminal.
- Bun - package manager and script runner.
| Path | Description |
|---|---|
app/ |
The Vite web app (React + TypeScript). Builds to app/dist/. |
shared/ |
Shared TypeScript sources consumed by app/ via the @hermes/shared alias. |
bin/dev, bin/prod |
Zero-setup runners (install, build, and start in one command). |
scripts/serve-on-gateway.sh |
Helper that points a gateway at the built app/dist/ and starts it. |
UPSTREAM.md, LICENSE |
Provenance and license (MIT, Copyright (c) 2025 Nous Research). |
- bun as the package manager and script runner.
- A running Hermes gateway (
hermes serveorhermes dashboard, FastAPI, default port9119).
The gateway is meant to run on loopback (127.0.0.1, the default).
Run the UI and the gateway on the same machine and reach it at 127.0.0.1.
Two runners install dependencies and start the app with no manual setup.
git clone https://github.com/przbadu/hermes-ui.git
cd hermes-ui
./bin/dev # dev server with hot reload; open http://127.0.0.1:5174
./bin/prod # build, then serve the bundle from the gateway (same-origin)bin/devproxies tohttp://127.0.0.1:9119by default; override withHERMES_GATEWAY_URL=http://host:port ./bin/dev.bin/prodpasses any extra arguments through tohermes serve, for example./bin/prod --port 9200.
Tip
For gateways using OAuth, prefer bin/prod: the login redirect must return to the same origin the app was served from, which the gateway-hosted path guarantees.
If you are an AI agent (Hermes, Claude Code, and similar), or you are asking one to set this up, follow AGENTS.md. It is a step-by-step runbook covering preflight checks, build, serving the bundle from the gateway (the manual path), verification, and rebuild.
The short version, from the repo root:
cd app && bun install && bun run build # build the static bundle
cd .. && ./bin/prod # serve it from the gateway (same-origin)The runbook deliberately covers only the manual path; an agent should not install or modify systemd services unless you explicitly ask.
There are three ways to run the UI. All of them preserve the same-origin property the gateway requires (see The Same-Origin Model).
Build the bundle, then start the gateway with HERMES_WEB_DIST pointing at the absolute path of app/dist.
cd app
bun install
bun run buildThen start the gateway with the built bundle:
HERMES_WEB_DIST="$(pwd)/dist" hermes serveOr use the helper, which resolves the absolute path and exports the variable for you:
./scripts/serve-on-gateway.shOpen the gateway's URL (default http://127.0.0.1:9119).
Because the UI is served by the gateway, everything is same-origin: cookies, CORS, and the WebSocket all work, and the gateway injects the session token into index.html automatically.
Important
HERMES_WEB_DIST must be an absolute path.
For local development with hot reload, run the Vite dev server.
The browser only ever talks to the Vite origin (http://127.0.0.1:5174); Vite proxies /api, /auth, and /login (including the WebSocket upgrade) to the gateway, keeping the browser same-origin with the dev server.
cd app
bun install
bun run devThe proxy targets http://127.0.0.1:9119 by default.
If the gateway runs on a different host or port, set HERMES_GATEWAY_URL before starting the dev server:
HERMES_GATEWAY_URL=http://127.0.0.1:9200 bun run devHERMES_GATEWAY_URL picks the single gateway the browser talks to same-origin through the proxy.
To whitelist additional gateways you can add by hand in Settings, list them in a repo-root config.json (see Whitelisting gateways).
If you want to serve the UI from your own web server, host the contents of app/dist/ as static files and proxy /api, /auth, and /login (with WebSocket upgrade support on /api) to the gateway, all under one domain.
Serving the static files and proxying the API paths on the same origin preserves the same-origin property the gateway requires.
The connection is editable at runtime in Settings -> Gateway, exactly like the desktop app. It defaults to the origin the app was served from, so the gateway-hosted and dev-proxy paths need no configuration.
You can point it at another gateway URL (an absolute https://host or a /prefix path on the serving origin) and choose token or OAuth authentication; the choice is saved in the browser.
You can save multiple gateways (personal, company, and so on) and switch between them; the list lives in the browser.
The same-origin constraint below still applies to whatever URL you enter.
HERMES_GATEWAY_URL picks the default gateway the dev server proxies same-origin.
To make additional gateways behave the same way - proxied same-origin so both token and OAuth work - copy config.example.json to config.json in the repo root and list their URLs:
{
"gateways": [
"http://203.0.113.10:9119",
"https://hermes.example.com"
]
}config.json is git-ignored, so it never leaks your gateway URLs into version control.
You can also pass them by env: HERMES_GATEWAY_WHITELIST=url1,url2 bin/dev.
The dev server merges these with HERMES_GATEWAY_URL into a whitelist and hands it to the app.
Each whitelisted gateway is then folded to the serving origin: the dev proxy routes every request (and the WebSocket) to whichever gateway is currently active, chosen per-request from a validated marker, so only whitelisted origins are ever reachable (not an open proxy).
Because it is proxied same-origin, adding a whitelisted gateway in Settings connects exactly like the default - OAuth sign-in and cookie sessions included.
This does not add anything to your saved gateway list - you still add each gateway yourself in Settings; the whitelist only decides which ones the dev proxy will carry.
It is a dev-time mechanism: bin/dev (Vite) injects the whitelist, and the gateway-hosted production path does not read config.json.
Loopback gateways (localhost / 127.0.0.1) are always allowed and never need a config.json entry.
Two caveats worth knowing:
- OAuth needs the gateway to trust the dev origin. Because the proxy preserves the dev Host, the gateway builds its OAuth
redirect_uriashttp://127.0.0.1:5174/auth/callback. Sign-in only completes if that gateway's OAuth provider trustshttp://127.0.0.1:5174as a redirect URI (the local default gateway, and self-hosted gateways you can configure, do; a hosted gateway with only its own https callback registered will fail at/loginwithredirect_uri_mismatch). For a gateway that does not trust the dev origin, use a session token instead. - Reach a remote gateway over its http origin/IP, not an https domain. Proxying to an
https://gateway domain (especially one fronted by a CDN) can fail the Node-side TLS handshake (SSL alert number 40). Listing the gateway'shttp://host:portorigin avoids this and is the recommended form for a self-hosted gateway.
Each gateway's session cookie is namespaced per target inside the dev cookie jar, so switching gateways never leaks one gateway's session to another, and switching back keeps the prior session.
Note
Known limitation: two browser tabs pointed at two different active gateways is unsupported in dev (the active gateway lives in shared browser storage); use one gateway per browser profile if you need two at once.
The gateway is locked down to same-origin browsers and cannot be talked to cross-origin:
- CORS is hardcoded to localhost origins, so a browser served from any other origin is rejected.
- Session cookies are
SameSite=Lax, so they are not sent on cross-site requests. - The WebSocket rejects foreign origins and closes the connection with close code
4403.
Serving the built bundle from the gateway itself (option A) makes the UI, REST, auth, and WebSocket all share one origin, so cookies, CORS, and the WebSocket all work with no extra configuration. The dev proxy (option B) and a reverse proxy (option C) are the two ways to preserve that same-origin property without hosting on the gateway directly.
When the app itself is served from loopback (localhost or 127.0.0.1), it can reach any other loopback gateway directly, even on a different port, with no proxy.
Those origins are same-site (only the port differs), and everything the browser needs already lines up: the gateway's CORS allows localhost origins, and the app sends its credentials in an X-Hermes-Session-Token header for REST and a ?token= query param for the WebSocket, so no cookie ever has to cross origins.
This is why a localhost gateway just works once you add it in Settings -> Gateway, and why it does not need a config.json entry to be reachable.
OAuth cookie sessions are the one thing that still cannot cross origins directly: the gateway sends no Access-Control-Allow-Credentials, so its SameSite=Lax session cookie is never accepted cross-origin.
For a cross-port loopback gateway you want to sign into with OAuth, whitelist it (see Whitelisting gateways) so the dev proxy folds it same-origin, or use a session token.
The blanket same-origin requirement above still holds for every non-loopback origin that is not whitelisted through the dev proxy.
The gateway's mount_spa() serves a static directory selected by the HERMES_WEB_DIST environment variable:
- The directory is read from
HERMES_WEB_DISTand falls back to a bundledweb_distwhen the variable is unset. - The bundle's
assetssubdirectory is mounted at/assets, so the gateway expects<HERMES_WEB_DIST>/assetsto exist. - All other paths fall back to
<HERMES_WEB_DIST>/index.htmlfor SPA client-side routing. - Before serving
index.html, the gateway injects the session token aswindow.__HERMES_SESSION_TOKEN__in a<script>tag placed just before</head>, so the SPA can authenticate against protected endpoints in loopback/token mode.
Note
hermes serve respects HERMES_SERVE_HEADLESS=1 and will refuse to serve the SPA when it is set; leave it unset to host the UI.
The Vite build uses base: './' (app/vite.config.ts), so asset URLs in the built index.html are relative (for example ./assets/index-*.js), which resolves correctly when the bundle is served at the domain root.
| Symptom | Likely cause and fix |
|---|---|
| Blank page or 404 on assets | HERMES_WEB_DIST is wrong or not absolute. It must be the absolute path to app/dist, and that directory must contain both index.html and an assets/ subdirectory. |
WebSocket closes with code 4403 |
The app is not same-origin with the gateway. Serve it via option A, B, or C so the browser origin matches the gateway. |
| Cross-origin fetch failures (CORS errors, missing cookies) | Same root cause as the 4403 case - the UI is served from an origin the gateway does not trust. Make it same-origin. |
hermes serve returns 404 JSON for every page |
Either the bundle was not found at HERMES_WEB_DIST, or HERMES_SERVE_HEADLESS=1 is set (which disables the SPA on purpose). |
Milestones tracked in PLAN.md:
- M0 - Foundation: extraction and the web bridge.
- M1 - Boot and auth verification against a real gateway.
- M2 - PWA layer.
- M3 - Feature gating and web polish.
- M4 - Mobile layout pass.
- M5 - Capacitor / Android.
- M6 - Store release and optional desktop wrap.
See the open issues for a full list of proposed features and known issues.
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
- Fork the project.
- Create your feature branch (
git checkout -b feat/amazing-feature). - Run the checks in
app/:bun run typecheck,bun run lint, andbun run test:ui. - Commit your changes (
git commit -m 'feat: add amazing feature'). - Push to the branch (
git push origin feat/amazing-feature). - Open a pull request.
Keep local modifications minimal and centralized in app/src/web-bridge/ so upstream diffs stay clean (see UPSTREAM.md).
Distributed under the MIT License. See LICENSE for more information.
Copyright (c) 2025 Nous Research.
- Nous Research - authors of the Hermes Agent and the original desktop renderer this app is extracted from.
- Best-README-Template - the structure this README follows.
- Shields.io - the badges above.