Previously the on-device Web Speech API (Chrome/Edge/Safari) was always preferred, and the configured external transcription endpoint was only used as a fallback when the browser had no speech API at all (Firefox). Flip the priority in Composer.tsx: if a transcription endpoint is configured in Settings, use it for dictation regardless of browser support, since it's generally far more accurate. Fall back to the browser's on-device SpeechRecognition only when no endpoint is set. Update the Settings hint text and README to describe the new behavior. Rebuilt and redeployed to the repo root.
9.2 KiB
Hermes PWA
A mobile-first Progressive Web App front end for a self-hosted Hermes Agent (Nous Research). It talks directly to the Hermes Agent API Server — the OpenAI-compatible HTTP + SSE endpoint — so you can chat with your agent, browse sessions, and use text, voice, and images from an installable app.
Branding (logo, colour scheme, app icons) follows derez.ai; the logo in the sidebar links to the homepage.
Which URL do I use? (dashboard vs API server)
Hermes exposes two different servers. This app needs the second one.
| URL | What it is | Auth | Works with this app? |
|---|---|---|---|
https://your-host/ (e.g. beaver.derez.ai) |
The web dashboard | username + password → session cookie | ❌ No. It only allows CORS from its own origin and never sends Access-Control-Allow-Credentials: true, so a separately-hosted app can't use its cookie. |
https://api.your-host/ (e.g. api.beaver.derez.ai, default port 8642) |
The API Server | Authorization: Bearer <API_SERVER_KEY> |
✅ Yes — this is the one. |
So enter the API server URL and your API_SERVER_KEY on the config
screen. There is no QR login and no username/password here — the API server uses
a bearer token.
Enabling the API server (on the Hermes host)
If it isn't already running, add to ~/.hermes/.env and start the gateway:
API_SERVER_ENABLED=true
API_SERVER_KEY=choose-a-long-random-secret
# Only needed if a browser calls the API directly (see CORS below):
# API_SERVER_CORS_ORIGINS=https://your-pwa-origin
hermes gateway # → [API Server] API server listening on http://127.0.0.1:8642
Verify from anywhere:
curl -s https://api.your-host/health
# {"status": "ok", "platform": "hermes-agent", "version": "..."}
curl -s -H "Authorization: Bearer $API_SERVER_KEY" https://api.your-host/v1/capabilities
Reaching the API server (CORS)
The API server has no browser CORS by default. Two options:
Option A — same origin (recommended): use the bundled proxy
server/proxy.mjs serves the built app and proxies /api/* and /v1/* to
the API server, so everything is same-origin and CORS never applies.
You normally paste the API key into the app in the browser (it's saved in
that browser's localStorage). The proxy does not need the key:
HERMES_TARGET=https://api.beaver.derez.ai PORT=8787 ./run.sh serve
# open http://localhost:8787
Then in the app's config screen, tick "Same origin (behind the bundled proxy)" and paste your API key. Done — no key on the server, no CORS.
HERMES_API_KEYis optional. Only set it if you'd rather keep the key on the proxy so it never reaches the browser:HERMES_TARGET=https://api.beaver.derez.ai HERMES_API_KEY=your-key PORT=8787 ./run.sh serve
Option B — direct, cross-origin
Set an explicit allowlist on the Hermes host and enter the API URL + key in the app's config screen:
API_SERVER_CORS_ORIGINS=https://your-pwa-origin
For local development you can skip CORS entirely with a dev proxy:
# .env
HERMES_DEV_TARGET=https://api.beaver.derez.ai
Quick start
./run.sh # dev server -> http://localhost:5173
Then on the config screen enter your API server URL and API key.
run.sh modes
./run.sh # dev server (http://localhost:5173)
./run.sh build # production build, deployed straight into the repo root
./run.sh serve # build if needed, then host + proxy /api/*, /v1/* (needs HERMES_TARGET)
./run.sh preview # build, then serve the build with vite preview
./run.sh icons # regenerate the PWA icon set
./run.sh typecheck # type-check without emitting
Deployment model: the repo root is the web root
The Vite project lives in app/. Running a build (./run.sh build or
cd app && npm run build) compiles it and then copies the output
(index.html, assets/, manifest.webmanifest, sw.js, workbox-*.js,
favicon.svg, icons/, LICENSE) directly into the repository root —
see app/scripts/deploy.mjs. Those deployed files are committed to git.
This means a webserver that simply serves the raw repository checkout as
static files (no build step, no custom MIME types, no reverse-proxy config)
works out of the box: index.html in the repo root already references
hashed, plain .js/.css assets — nothing points at /src/*.tsx in
production, so there's no "unauthorized MIME type" issue.
To publish a new version:
cd app
npm run build # builds app/dist, then syncs it into the repo root
cd ..
git add -A
git commit -m "Deploy: <description>"
git push # or however the server pulls the repo
Note:
app/(source, dev config,node_modules) is still present in the checkout, so if the webserver serves the whole repo folder, the raw TypeScript source underapp/srcis technically reachable as plain text. It is never executed as a module by the deployed app (the deployedindex.htmlonly references the builtassets/*.js), so this is not a functional issue — just something to be aware of if source privacy matters to you.
| Variable | Used by | Meaning |
|---|---|---|
HERMES_TARGET |
serve |
API server base URL to proxy to |
HERMES_API_KEY |
serve |
Optional — injected as Authorization: Bearer … |
PORT |
serve |
Port to listen on (default 8787) |
HERMES_DEV_TARGET |
dev |
API server base URL to proxy to during development |
Features
- Config screen — add one or more API-server connections (URL, API key, optional profile prefix, custom headers) with a "Test" probe.
- Sessions — list, open, create, rename, fork, and delete; filter by title.
- Streaming chat — token-by-token replies over SSE, with live tool-call cards, cancel, and mid-turn steer.
- Images — attach/paste/drag-drop; sent inline as
data:URLs. - Voice — dictation via the browser's Web Speech API and spoken replies via
on-device speech synthesis. Tap the mic to speak your prompt; tap again to stop.
Requires a secure context (HTTPS or
localhost) and a browser with the Web Speech API (Chrome, Edge, Safari). Note: Chrome's dictation sends audio to Google's servers. - Installable PWA — offline app shell, home-screen install, standalone display.
Notes & limitations
- Voice is client-side. The API server has no
/transcribeor/ttsendpoint. If a transcription endpoint is configured in Settings (any OpenAI-compatible/v1/audio/transcriptionsserver), it's used for dictation; otherwise dictation falls back to the browser's on-deviceSpeechRecognition. Spoken replies always usespeechSynthesis. Support varies by browser (best on Chrome/Edge/Safari). - Profiles. The API server routes profiles via a
/p/<profile>/…prefix, and each profile has its ownAPI_SERVER_KEY. Add one connection per profile to see them all; the sidebar switches between connections. - Session ownership. The API server can create and drive its own sessions, but it refuses to write to a session owned by another surface (CLI, TUI, dashboard, messaging) — those open read-only.
- Response shapes. The Sessions REST API's exact JSON envelope isn't fully
documented, so the client normalises several shapes (
sessions/data/items,id/session_id, string/array content). If a field looks off on your version, it's a one-line tweak inapp/src/api/client.ts.
Project layout
index.html, assets/, manifest.webmanifest, # repo root = deployed build
sw.js, workbox-*.js, favicon.svg, icons/, # output (see Deployment model above)
LICENSE
app/ # Vite project (source, dev config)
index.html # dev entry (-> /src/main.tsx); NOT served in production
src/
api/
client.ts # HermesClient: bearer auth, sessions REST, SSE chat
types.ts # API server types + normalised UI types
hooks/
useSpeechRecognition.ts # browser dictation
useTts.ts # browser speech synthesis
lib/
config.ts # saved connections (localStorage)
format.ts # time / token formatting
markdown.tsx # tiny safe markdown renderer
state/
store.ts # zustand store: connections, sessions, streaming
components/ # ConfigScreen, ConnectionForm, Sidebar, ChatView, Composer, …
scripts/
gen-icons.mjs # dependency-free PNG icon generator
deploy.mjs # copies app/dist/* into the repo root after `vite build`
server/
proxy.mjs # optional static host + /api,/v1 reverse proxy (no deps)
License
Licensed under the PolyForm Internal Use License 1.0.0 — internal
business use only; redistribution is not permitted. The license text is also
shipped in the build output as dist/LICENSE.
API endpoints used
GET /health, GET /v1/capabilities, GET /v1/models,
GET|POST /api/sessions, GET|PATCH|DELETE /api/sessions/{id},
GET /api/sessions/{id}/messages, POST /api/sessions/{id}/fork,
POST /api/sessions/{id}/chat/stream (SSE), and
POST /v1/runs/{id}/stop|steer|approval|clarify.