* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Hermes Web UI
Hermes Agent is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, that remembers what it learns and gets more capable the longer it runs.
Hermes WebUI is a lightweight, dark-themed web app interface in your browser for Hermes Agent. Full parity with the CLI experience - everything you can do from a terminal, you can do from this UI. No build step, no framework, no bundler. Just Python and vanilla JS.
Layout: three-panel. Left sidebar for sessions and navigation, center for chat, right for workspace file browsing. Model, profile, and workspace controls live in the composer footer — always visible while composing. A circular context ring shows token usage at a glance. All settings and session tools are in the Hermes Control Center (launcher at the sidebar bottom).
|
Light mode with full profile support |
Customize your settings, configure a password |
Workspace file browser with inline preview |
Session projects, tags, and tool call cards |
This gives you nearly 1:1 parity with Hermes CLI from a convenient web UI which you can access securely through an SSH tunnel from your Hermes setup. Single command to start this up, and a single command to SSH tunnel for access on your computer. Every single part of the web UI uses your existing Hermes agent and existing models, without requiring any additional setup.
Why Hermes
Most AI tools reset every session. They don't know who you are, what you worked on, or what conventions your project follows. You re-explain yourself every time.
Hermes retains context across sessions, runs scheduled jobs while you're offline, and gets smarter about your environment the longer it runs. It uses your existing Hermes agent setup, your existing models, and requires no additional configuration to start.
What makes it different from other agentic tools:
- Persistent memory — user profile, agent notes, and a skills system that saves reusable procedures; Hermes learns your environment and does not have to relearn it
- Self-hosted scheduling — cron jobs that fire while you're offline and deliver results to Telegram, Discord, Slack, Signal, email, and more
- 10+ messaging platforms — the same agent available in the terminal is reachable from your phone
- Self-improving skills — Hermes writes and saves its own skills automatically from experience; no marketplace to browse, no plugins to install
- Provider-agnostic — OpenAI, Anthropic, Google, DeepSeek, OpenRouter, and more
- Orchestrates other agents — can spawn Claude Code or Codex for heavy coding tasks and bring the results back into its own memory
- Self-hosted — your conversations, your memory, your hardware
vs. the field (landscape is actively shifting — see HERMES.md for the full breakdown):
| OpenClaw | Claude Code | Codex CLI | OpenCode | Hermes | |
|---|---|---|---|---|---|
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes |
| Scheduled jobs (self-hosted) | Yes | No‡ | No | No | Yes |
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview) | No | No | Yes (10+) |
| Web UI (self-hosted) | Dashboard only | No | No | Yes | Yes |
| Self-improving skills | Partial | No | No | No | Yes |
| Python / ML ecosystem | No (Node.js) | No | No | No | Yes |
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Yes |
| Open source | Yes (MIT) | No | Yes | Yes | Yes |
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
‡ Claude Code has cloud-managed scheduling (Anthropic infrastructure) and session-scoped /loop; no self-hosted cron
The closest competitor is OpenClaw — both are always-on, self-hosted, open-source agents with memory, cron, and messaging. The key differences: Hermes writes and saves its own skills automatically as a core behavior (OpenClaw's skill system centers on a community marketplace); Hermes is more stable across updates (OpenClaw has documented release regressions and ClawHub has had security incidents involving malicious skills); and Hermes runs natively in the Python ecosystem. See HERMES.md for the full side-by-side.
Quick start
Run the repo bootstrap:
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
cd hermes-webui
python3 bootstrap.py
Or keep using the shell launcher:
./start.sh
The bootstrap will:
- Detect Hermes Agent and, if missing, attempt the official installer (
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash). - Find or create a Python environment with the WebUI dependencies.
- Start the web server and wait for
/health. - Open the browser unless you pass
--no-browser. - Drop you into a first-run onboarding wizard inside the WebUI.
Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with hermes model instead of trying to replicate the full CLI setup in-browser.
Docker
Pre-built images (amd64 + arm64) are published to GHCR on every release:
Make sure the HERMES_WEBUI_STATE_DIR (by default ~/.hermes/webui-mvp, as detailed in the .env.example file) folder exist with the UID/GID of the owner of the .hermes folder.
The container will also mount your configured "workspace" (also from the example .env.example) as /workspace. adapt the location as needed.
docker pull ghcr.io/nesquena/hermes-webui:latest
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 ghcr.io/nesquena/hermes-webui:latest
Or run with Docker Compose (recommended):
# Check the docker-compose.yml and make sure to adapt as needed, at minimum WANTED_UID/WANTED_GID
docker compose up -d
Or build locally:
docker build -t hermes-webui .
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 hermes-webui
Open http://localhost:8787 in your browser.
To enable password protection:
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret ghcr.io/nesquena/hermes-webui:latest
Note: By default, Docker Compose binds to
127.0.0.1(localhost only). To expose on a network, change the port to"8787:8787"indocker-compose.ymland setHERMES_WEBUI_PASSWORDto enable authentication.
Two-container setup (Agent + WebUI)
If you run the Hermes Agent in its own Docker container and want the WebUI in a separate container:
docker compose -f docker-compose.two-container.yml up -d
This starts both containers with shared volumes:
hermes-home— shared~/.hermesfor config, sessions, skills, memoryhermes-agent-src— the agent's source code, mounted into the WebUI container so it can install the agent's Python dependencies at startup
Volume type: The compose files use named Docker volumes by default. If you prefer bind mounts to an existing directory (e.g. for sharing state with an agent container you already run), both containers must mount the same host path — the agent writes to
/root/.hermes, the WebUI reads from/home/hermeswebui/.hermes. Seedocker-compose.two-container.ymlfor a bind-mount example.
The WebUI's init script automatically installs hermes-agent and all its dependencies (openai, anthropic, etc.) into its own Python environment on first boot. Subsequent restarts reuse the installed packages.
How it works: The WebUI imports hermes-agent's Python modules directly (not via HTTP). The shared volume makes the agent source available, and the init script runs
uv pip installto set up the dependencies. Both containers share the same~/.hermesdirectory for config and state.
See docker-compose.two-container.yml for the full configuration.
Running alongside hermes-dashboard (three-container setup)
To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a shared volume, use the three-container Compose file:
docker compose -f docker-compose.three-container.yml up -d
This brings up:
hermes-agent— gateway API on port 8642hermes-dashboard— monitoring UI on port 9119hermes-webui— browser chat interface on port 8787
All three services share the same hermes-home named volume so config,
sessions, skills, and memory are consistent across all surfaces.
Why UIDs must match
The hermes-home volume is a bind-mount in practice — all three containers
write to the same filesystem tree under ~/.hermes. If the containers run
as different UIDs, whichever container creates a file first becomes its
owner, and the others hit PermissionError on subsequent writes.
The fix is to make all containers run as your host user's UID and GID.
Variable name asymmetry
⚠️ The two image families use different environment variable names for the UID/GID setting:
Image Variable nousresearch/hermes-agent(agent + dashboard)HERMES_UID/HERMES_GIDghcr.io/nesquena/hermes-webuiWANTED_UID/WANTED_GIDYou must set both pairs when using a
.envfile.
Recommended setup
For a standard Linux user (UID ≥ 1000):
# Create a .env file with your host UID/GID
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
# hermes-agent / hermes-dashboard
echo "HERMES_UID=$(id -u)" >> .env
echo "HERMES_GID=$(id -g)" >> .env
For NAS/Unraid deployments where a fixed service account is preferred, use
10000:10000 (or your NAS service UID) instead of $(id -u).
If you get PermissionError on an existing ~/.hermes directory, run
the one-time ownership fix:
chown -R $(id -u):$(id -g) ~/.hermes
Volume mount mode
The dashboard container needs read-write access to the shared volume
(it writes session logs and dashboard state). Do not add :ro to the
hermes-home volume in hermes-dashboard's volumes: entry.
See docker-compose.three-container.yml for the full reference configuration.
What start.sh discovers automatically
| Thing | How it finds it |
|---|---|
| Hermes agent dir | HERMES_WEBUI_AGENT_DIR env, then ~/.hermes/hermes-agent, then sibling ../hermes-agent |
| Python executable | Agent venv first, then .venv in this repo, then system python3 |
| State directory | HERMES_WEBUI_STATE_DIR env, then ~/.hermes/webui-mvp |
| Default workspace | HERMES_WEBUI_DEFAULT_WORKSPACE env, then ~/workspace, then state dir |
| Port | HERMES_WEBUI_PORT env or first argument, default 8787 |
If discovery finds everything, nothing else is required.
Overrides (only needed if auto-detection misses)
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/path/to/python
export HERMES_WEBUI_PORT=9000
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
./start.sh
Or inline:
HERMES_WEBUI_AGENT_DIR=/custom/path ./start.sh 9000
Full list of environment variables:
| Variable | Default | Description |
|---|---|---|
HERMES_WEBUI_AGENT_DIR |
auto-discovered | Path to the hermes-agent checkout |
HERMES_WEBUI_PYTHON |
auto-discovered | Python executable |
HERMES_WEBUI_HOST |
127.0.0.1 |
Bind address |
HERMES_WEBUI_PORT |
8787 |
Port |
HERMES_WEBUI_STATE_DIR |
~/.hermes/webui-mvp |
Where sessions and state are stored |
HERMES_WEBUI_DEFAULT_WORKSPACE |
~/workspace |
Default workspace |
HERMES_WEBUI_DEFAULT_MODEL |
openai/gpt-5.4-mini |
Default model |
HERMES_WEBUI_PASSWORD |
(unset) | Set to enable password authentication |
HERMES_HOME |
~/.hermes |
Base directory for Hermes state (affects all paths) |
HERMES_CONFIG_PATH |
~/.hermes/config.yaml |
Path to Hermes config file |
Accessing from a remote machine
The server binds to 127.0.0.1 by default (loopback only). If you are running
Hermes on a VPS or remote server, use an SSH tunnel from your local machine:
ssh -N -L <local-port>:127.0.0.1:<remote-port> <user>@<server-host>
Example:
ssh -N -L 8787:127.0.0.1:8787 user@your.server.com
Then open http://localhost:8787 in your local browser.
start.sh will print this command for you automatically when it detects you
are running over SSH.
Accessing on your phone with Tailscale
Tailscale is a zero-config mesh VPN built on WireGuard. Install it on your server and your phone, and they join the same private network -- no port forwarding, no SSH tunnels, no public exposure.
The Hermes Web UI is fully responsive with a mobile-optimized layout (hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls), so it works well as a daily-driver agent interface from your phone.
Setup:
- Install Tailscale on your server and your iPhone/Android.
- Start the WebUI listening on all interfaces with password auth enabled:
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
- Open
http://<server-tailscale-ip>:8787in your phone's browser (find your server's Tailscale IP in the Tailscale app or withtailscale ip -4on the server).
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth protects the UI at the application level. You can add it to your home screen for an app-like experience.
Tip: If using Docker, set
HERMES_WEBUI_HOST=0.0.0.0in yourdocker-compose.ymlenvironment (already the default) and setHERMES_WEBUI_PASSWORD.
Manual launch (without start.sh)
If you prefer to launch the server directly:
cd /path/to/hermes-agent # or wherever sys.path can find Hermes modules
HERMES_WEBUI_PORT=8787 venv/bin/python /path/to/hermes-webui/server.py
Note: use the agent venv Python (or any Python environment that has the Hermes agent dependencies installed). System Python will be missing openai, httpx, and other required packages.
Health check:
curl http://127.0.0.1:8787/health
Running tests
Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
cd hermes-webui
pytest tests/ -v --timeout=60
Or using the agent venv explicitly:
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v
Tests run against an isolated server on port 8788 with a separate state directory. Production data and real cron jobs are never touched. Current count: 1898 tests across 53 test files.
Features
Chat and agent
- Streaming responses via SSE (tokens appear as they are generated)
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter, MiniMax, Z.AI); dynamic model dropdown populated from configured keys
- Send a message while one is processing -- it queues automatically
- Edit any past user message inline and regenerate from that point
- Retry the last assistant response with one click
- Cancel a running task directly from the composer footer (Stop button next to Send)
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
- Approval card for dangerous shell commands (allow once / session / always / deny)
- SSE auto-reconnect on network blips (SSH tunnel resilience)
- File attachments persist across page reloads
- Message timestamps (HH:MM next to each message, full date on hover)
- Code block copy button with "Copied!" feedback
- Syntax highlighting via Prism.js (Python, JS, bash, JSON, SQL, and more)
- Safe HTML rendering in AI responses (bold, italic, code converted to markdown)
- rAF-throttled token streaming for smoother rendering during long responses
- Context usage indicator in composer footer -- token count, cost, and fill bar (model-aware)
Sessions
- Create, rename, duplicate, delete, search by title and message content
- Session actions via
⋯dropdown per session — pin, move to project, archive, duplicate, delete - Pin/star sessions to the top of the sidebar (gold indicator)
- Archive sessions (hide without deleting, toggle to show)
- Session projects -- named groups with colors for organizing sessions
- Session tags -- add #tag to titles for colored chips and click-to-filter
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
- Download as Markdown transcript, full JSON export, or import from JSON
- Sessions persist across page reloads and SSH tunnel reconnects
- Browser tab title reflects the active session name
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
- Token/cost display -- input tokens, output tokens, estimated cost shown per conversation (toggle in Settings or
/usagecommand)
Workspace file browser
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
- Breadcrumb navigation with clickable path segments
- Preview text, code, Markdown (rendered), and images inline
- Edit, create, delete, and rename files; create folders
- Binary file download (auto-detected from server)
- File preview auto-closes on directory navigation (with unsaved-edit guard)
- Git detection -- branch name and dirty file count badge in workspace header
- Right panel is drag-resizable
- Syntax highlighted code preview (Prism.js)
Voice input
- Microphone button in the composer (Web Speech API)
- Tap to record, tap again or send to stop
- Live interim transcription appears in the textarea
- Auto-stops after ~2s of silence
- Appends to existing textarea content (doesn't replace)
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
Profiles
- Profile chip in the composer footer -- dropdown showing all profiles with gateway status and model info
- Gateway status dots (green = running), model info, skill count per profile
- Profiles management panel -- create, switch, and delete profiles from the sidebar
- Clone config from active profile on create
- Optional custom endpoint fields on create -- Base URL and API key written into the profile's
config.yamlat creation time, so Ollama, LMStudio, and other local endpoints can be configured without editing files manually - Seamless switching -- no server restart; reloads config, skills, memory, cron, models
- Per-session profile tracking (records which profile was active at creation)
Authentication and security
- Optional password auth -- off by default, zero friction for localhost
- Enable via
HERMES_WEBUI_PASSWORDenv var or Settings panel - Signed HMAC HTTP-only cookie with 24h TTL
- Minimal dark-themed login page at
/login - Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
- 20MB POST body size limit
- CDN resources pinned with SRI integrity hashes
Themes
- 7 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord, OLED
- Switch via Settings panel dropdown (instant live preview) or
/themecommand - Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Custom themes: define a
:root[data-theme="name"]CSS block and it works — see THEMES.md
Settings and configuration
- Hermes Control Center (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
- Send key: Enter (default) or Ctrl/Cmd+Enter
- Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via
/usagecommand) - Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Background agent error alerts -- banner when a non-active session encounters an error
Slash commands
- Type
/in the composer for autocomplete dropdown - Built-in:
/help,/clear,/compress [focus topic],/compact(alias),/model <name>,/workspace <name>,/new,/usage,/theme - Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
Panels
- Chat -- session list, search, pin, archive, projects, new conversation
- Tasks -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
- Skills -- list all skills by category, search, preview, create/edit/delete; linked files viewer
- Memory -- view and edit MEMORY.md and USER.md inline
- Profiles -- create, switch, delete agent profiles; clone config
- Todos -- live task list from the current session
- Spaces -- add, rename, remove workspaces; quick-switch from topbar
Mobile responsive
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
- Files slide-over panel from right edge
- Touch targets minimum 44px on all interactive elements
- Full-height chat/composer on phones without bottom-nav spacing
- Desktop layout completely unchanged
Architecture
server.py HTTP routing shell + auth middleware (~154 lines)
api/
auth.py Optional password authentication, signed cookies (~201 lines)
config.py Discovery, globals, model detection, reloadable config (~1110 lines)
helpers.py HTTP helpers, security headers (~175 lines)
models.py Session model + CRUD + CLI bridge (~377 lines)
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
routes.py All GET + POST route handlers (~2250 lines)
state_sync.py /insights sync — message_count to state.db (~113 lines)
streaming.py SSE engine, run_agent, cancel support (~660 lines)
updates.py Self-update check and release notes (~257 lines)
upload.py Multipart parser, file upload handler (~82 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines)
static/
index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive, themes (~1050 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1740 lines)
workspace.js File preview, file ops, git badge (~286 lines)
sessions.js Session CRUD, collapsible groups, search, reload recovery (~800 lines)
messages.js send(), SSE handlers, live streaming, session recovery (~655 lines)
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
commands.js Slash command autocomplete (~267 lines)
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
tests/
conftest.py Isolated test server (port 8788)
61 test files 961 test functions
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
State lives outside the repo at ~/.hermes/webui-mvp/ by default
(sessions, workspaces, settings, projects, last_workspace). Override with HERMES_WEBUI_STATE_DIR.
Docs
HERMES.md-- why Hermes, mental model, and detailed comparison to Claude Code / Codex / OpenCode / CursorROADMAP.md-- feature roadmap and sprint historyARCHITECTURE.md-- system design, all API endpoints, implementation notesTESTING.md-- manual browser test plan and automated coverage referenceCHANGELOG.md-- release notes per sprintSPRINTS.md-- forward sprint plan with CLI + Claude parity targetsTHEMES.md-- theme system documentation, custom theme guide
Contributors
Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly or incorporated via rebase — shapes the project, and we're grateful to everyone who has taken the time to contribute.
Major contributions
@aronprins — v0.50.0 UI overhaul (PR #242)
The biggest single contribution to the project: a complete UI redesign that moved model/profile/workspace controls into the composer footer, replaced the gear-icon settings panel with the Hermes Control Center (tabbed modal), removed the activity bar in favor of inline composer status, redesigned the session list with a ⋯ action dropdown, and added the workspace panel state machine. 26 commits, thoroughly designed and iterated through multiple review rounds.
@iRonin — Security hardening sprint (PRs #196–#204) Six consecutive security and reliability PRs: session memory leak fix (expired token pruning), Content-Security-Policy + Permissions-Policy headers, 30-second slow-client connection timeout, optional HTTPS/TLS support via environment variables, upstream branch tracking fix for self-update, and CLI session support in the file browser API. This is the kind of focused, high-quality security work that makes a self-hosted tool trustworthy.
@DavidSchuchert — German translation (PR #190)
Complete German locale (de) covering all UI strings, settings labels, commands, and system messages — and in doing so, stress-tested the i18n system and exposed several elements that weren't yet translatable, which got fixed as part of the same PR.
@Jordan-SkyLF — Live streaming, session recovery, workspace fallback (PRs #366, #367)
Three interlocking improvements: workspace fallback resolution so the server recovers gracefully when the configured workspace is deleted or unavailable; live reasoning cards that upgrade the generic thinking spinner to a real-time reasoning display as the model thinks; and durable session state recovery via localStorage so in-flight tool cards, partial assistant output, and the live SSE stream all survive a full page reload or session switch.
Feature contributions
@gabogabucho — Spanish locale + onboarding wizard (PRs #275, #285)
Full Spanish (es) locale covering all 175 UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch — the feature most responsible for new users actually getting started.
@bergeouss — Real-time gateway session sync (PR #274) Bridged the gateway session database (Telegram, Discord, Slack, etc.) into the WebUI sidebar with live SSE polling. Gateway sessions now appear alongside WebUI sessions in real time, without any changes to hermes-agent.
@ccqqlo — Terminal approval UX + custom model discovery + mobile close button (PRs #224, #225, #238, #333) A run of focused quality-of-life improvements: terminal tool approval prompts that stay visible long enough to actually be read, restored custom model API key discovery, and the redundant mobile close button fix that had been confusing users on narrow screens.
@kevin-ho — OLED theme (PR #168) Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display.
@Bobby9228 — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265) Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
@franksong2702 — Session title guard + breadcrumb nav (PRs #301, #302)
Two clean bug fixes / features: the session title guard that stops title_from() from overwriting user-renamed sessions after every turn, and clickable breadcrumb navigation in the workspace file preview panel.
@betamod — Security hardening (PR #171) A comprehensive security audit PR covering CSRF protection, SSRF guards, XSS escaping improvements, and the env race condition between concurrent agent sessions — foundational security work that shipped in v0.39.0.
@TaraTheStar — Bot name + thinking blocks + login refactor (PRs #132, #176, #181) Made the assistant display name configurable throughout the UI, added thinking/reasoning block display in chat, and refactored the login page to use template variables instead of inline string replacement.
@thadreber-web — CLI session bridge (PR #56) The original CLI session bridge: reads CLI sessions from the agent's SQLite state store and surfaces them in the WebUI sidebar. This was the first bridge between the CLI and WebUI session worlds.
@deboste — Reverse proxy auth + mobile responsive layout + model routing (PRs #3, #4, #5) Three of the very first community PRs: fixed EventSource/fetch to use the URL origin for reverse proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
Bug fix and security contributions
@Hinotoi-agent — Profile .env secret isolation (PR #351)
Fixed API key leakage between profiles on switch — switching from a profile with OPENAI_API_KEY to one without it left the key in the process environment for the duration of the session, effectively leaking credentials. A subtle and important security fix.
@lawrencel1ng — Bandit security fixes B310/B324/B110 + QuietHTTPServer (PR #354)
Systematic bandit security scan fixes: URL scheme validation before urlopen, MD5 usedforsecurity=False, and 40+ bare except: pass blocks replaced with proper logging — plus QuietHTTPServer to stop client-disconnect log spam from SSE streams.
@lx3133584 — CSRF fix for reverse proxy on non-standard ports (PR #360) Fixed CSRF rejection for deployments behind Nginx Proxy Manager or similar on non-standard ports — a real-world blocker for anyone hosting on a port other than 80/443.
@DelightRun — session_search fix for WebUI sessions (PR #356)
The session_search tool silently returned "Session database not available" in every WebUI session. Tracked down the missing SessionDB injection in the streaming path and fixed it.
@shaoxianbilly — Unicode filename downloads (PR #378)
Fixed UnicodeEncodeError crashes when downloading workspace files with Chinese, Japanese, or other non-ASCII names. Implemented proper Content-Disposition header with RFC 5987 filename*=UTF-8''... encoding.
@huangzt — Cancel interrupts agent (PR #244) Made the Cancel button actually interrupt the running agent and clean up UI state, rather than just hiding the button while the agent kept running.
@tgaalman — Thinking card fix (PR #169) Fixed top-level reasoning fields being missed in the thinking card display — an edge case in how Claude's extended thinking blocks surface in the API response.
@smurmann — Custom provider routing fix (PR #189) Fixed model routing for slash-prefixed custom provider models, which were being misrouted in the model selector. A precise fix for a real edge case in multi-provider setups.
@jeffscottward — Claude Haiku model ID fix (PR #145)
Caught and corrected the Claude Haiku model ID (3-5 → 4-5) immediately after the Anthropic release — the kind of quick community catch that keeps the model dropdown accurate.
@kcclaw001 — Credential redaction in API responses (PR #243) Added credential redaction to all API response paths so API keys, tokens, and other secrets in session data or error messages are masked before reaching the browser.
@mbac — Phantom "Custom" provider group fix (PR #191) Removed the phantom "Custom" optgroup that appeared in the model dropdown even when no custom provider was configured — a small but consistently confusing UI noise issue.
@andrewy-wizard — Chinese localization (PR #177)
Added Simplified Chinese (zh) locale to the WebUI. One of the first non-English locales and the most-used non-English locale in the codebase.
@mmartial — Docker UID/GID matching (PR #237) Added Docker support for running as an arbitrary UID/GID matching the host user, eliminating permission issues with bind-mounted volumes — essential for Docker deployments where the host user isn't UID 1000.
@vCillusion — pip package resolution fix (PR #76) Fixed agent dependency resolution to prefer packages from the venv's site-packages over the agent directory itself, preventing shadowing bugs when developing locally.
@carlytwozero — API key pass-through for non-Anthropic providers (PR #78)
Fixed api_key not being passed to AIAgent for non-Anthropic /anthropic providers — a quiet regression that silently broke any non-default provider.
@mangodxd — Type hints cleanup (PR #115) Added missing type hints across 10 files and corrected 9 inaccurate existing ones — the kind of maintenance work that makes the codebase easier to reason about.
@Argonaut790 — HTML entity decode + Traditional Chinese locale (PR #239)
Fixed double-escaping of HTML entities in renderMd() — LLM output containing <code> was being escaped a second time, rendering as literal text instead of the intended markdown. The same PR also completed the Simplified Chinese translation (40+ missing keys) and added a full Traditional Chinese (zh-Hant) locale.
@indigokarasu — Visual redesign proposal: icon rail + design token system + 7 themes (PR #213)
A CSS-only redesign of the full UI — proper design tokens (--bg-primary, --text-info, spacing scale), an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but directly shaped the design language and theme architecture that shipped in v0.50.0.
@zenc-cp — Anti-hallucination guard for ReAct loop (PR #133)
Added a streaming token buffer and post-run message scrub to streaming.py to detect and strip fake tool execution JSON that weaker models write inline instead of calling tools properly. A three-layer approach: ephemeral anti-hallucination prompt, live token filtering, and session history cleanup. The pattern influenced later streaming.py improvements.
Want to contribute? See ARCHITECTURE.md for the codebase layout and TESTING.md for how to run the test suite. The best contributions are focused, well-tested, and solve a real problem — exactly what every person on this list did.
Repo
git@github.com:nesquena/hermes-webui.git

