hermes-webui Documentation

Hermes WebUI: The best way to use Hermes Agent from the web or from your phone!

18,982 stars Python Python 37 files · ~135,573 tokens #agent#ai-agents#hermes#hermes-agent#nous-research
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy hermes-webui on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name hermes-webui -p 8642:8642 --restart always ghcr.io/nesquena/hermes-webui:latest
Configured Services & Ports
⚙️ hermes-agent
image: nousresearch/hermes-agent:latest
🔌 Ports: ${HERMES_AGENT_BIND:-127.0.0.1}:8642:8642
⚙️ hermes-dashboard
image: nousresearch/hermes-agent:latest
🔌 Ports: ${HERMES_DASHBOARD_BIND:-127.0.0.1}:9119:9119
⚙️ hermes-webui
image: ghcr.io/nesquena/hermes-webui:latest
🔌 Ports: 127.0.0.1:8787:8787
docker-compose.yml
yaml (docker-compose.yml)
# Three-container Docker Compose: Hermes Agent + Dashboard + WebUI
#
# QUICK START:
#   docker compose -f docker-compose.three-container.yml up -d
#   Open http://localhost:8787 (chat) and http://localhost:9119 (dashboard)
#   Remote access (issue #6876): echo "HERMES_DASHBOARD_BIND=0.0.0.0" >> .env
#   and echo "API_SERVER_KEY=<16+ chars>" >> .env, then recreate the stack.
#   The WebUI reports "Hermes agent is not responding" when the gateway
#   listener is down — API_SERVER_KEY must be set for port 8642 to open.
#
# This extends the two-container setup with the Hermes Dashboard for
# monitoring agent activity, sessions, and resource usage.
#
# Services:
#   hermes-agent     — gateway API on port 8642 (CLI, Telegram, cron, tools)
#   hermes-dashboard — monitoring dashboard on port 9119
#   hermes-webui     — browser chat interface on port 8787
#
# All three share the same hermes-home volume so config, sessions,
# skills, and memory are consistent across all surfaces.
#
# WHEN NOT TO USE THIS:
#   - You hit "Permission denied" trying to share an existing ~/.hermes directory
#     → use docker-compose.yml (single-container) instead, OR
#     → keep this file but switch to NAMED VOLUMES (the default) instead of bind mounts
#   - You're on Podman 3.4 or older without keep-id namespace support
#     → see https://github.com/sunnysktsang/hermes-suite for an all-in-one image
#
# KNOWN LIMITATION (#681): tools triggered from the WebUI run in the WebUI
# container, not the agent container. See docker-compose.two-container.yml
# for context.
#
# NOTE ON VOLUMES:
#   This file uses named Docker volumes (hermes-home, hermes-agent-src) which
#   work out of the box. If you prefer bind mounts (e.g. to an existing
#   directory), see docker-compose.two-container.yml for a bind-mount example.
#   When using bind mounts, ALL THREE containers must mount the same host path
#   AND run as the same UID/GID (set via the UID/GID env vars below).

services:
  hermes-agent:
    image: nousresearch/hermes-agent:latest
    container_name: hermes-agent
    command: gateway run
    ports:
      # Bind to loopback by default. For remote access (issue #6876), set
      # HERMES_AGENT_BIND=0.0.0.0 in .env — the gateway requires
      # API_SERVER_KEY (>=16 chars) to open the listener at all.
      - "${HERMES_AGENT_BIND:-127.0.0.1}:8642:8642"
    volumes:
      # Persist config, state, sessions, skills, memory across restarts
      - hermes-home:/home/hermes/.hermes
      # Expose agent source so the WebUI can install dependencies from it
      - hermes-agent-src:/opt/hermes
    environment:
      - HERMES_HOME=/home/hermes/.hermes
      # Align UID/GID across containers sharing the hermes-home volume.
      # Defaults to 1000 to match WANTED_UID/WANTED_GID in the webui service.
      - HERMES_UID=${UID:-1000}
      - HERMES_GID=${GID:-1000}
      # Gateway API server (port 8642). The WebUI's Tasks/cron health probe
      # reaches this listener via HERMES_API_URL below. The agent only starts
      # the listener when API_SERVER_KEY is a usable value (>=16 chars); set
      # it in .env to enable the gateway API. Without a key the gateway daemon
      # still runs (messaging + cron) but port 8642 stays unbound and the
      # WebUI reports "Gateway endpoint not reachable".
      - API_SERVER_ENABLED=true
      - API_SERVER_HOST=0.0.0.0
      - API_SERVER_KEY=${API_SERVER_KEY:-}
      # Bind-mount permission handling for the agent — narrow set of overrides.
      # NOTE: The agent's HERMES_HOME_MODE applies to the HERMES_HOME *directory*
      # mode (default 0700) — NOT to credential files like the WebUI's variant.
      # If you set this, you MUST keep the owner-execute bit so the agent can
      # traverse its own home directory. 0640 BREAKS the agent (no x bit → no
      # traversal). Use 0750 for group-traversable or 0701 for x-only.
      # The agent's container detection (/.dockerenv) already auto-skips
      # credential chmod inside Docker, so HERMES_SKIP_CHMOD is redundant here.
      # - HERMES_HOME_MODE=0750
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 4G
          cpus: "2.0"
    networks:
      - hermes-net

  hermes-dashboard:
    image: nousresearch/hermes-agent:latest
    container_name: hermes-dashboard
    command: dashboard --host 0.0.0.0 --insecure
    ports:
      # Loopback-only by default (the dashboard runs --insecure). To reach the
      # dashboard from another machine (issue #6876), set
      # HERMES_DASHBOARD_BIND=0.0.0.0 in .env and put it behind auth / a
      # reverse proxy.
      - "${HERMES_DASHBOARD_BIND:-127.0.0.1}:9119:9119"
    volumes:
      - hermes-home:/home/hermes/.hermes
    environment:
      - HERMES_HOME=/home/hermes/.hermes
      # Align UID/GID across containers sharing the hermes-home volume.
      # Defaults to 1000 to match WANTED_UID/WANTED_GID in the webui service.
      - HERMES_UID=${UID:-1000}
      - HERMES_GID=${GID:-1000}
      # Dashboard connects to the gateway for health/session data
      - GATEWAY_HEALTH_URL=http://hermes-agent:8642
    depends_on:
      - hermes-agent
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "0.5"
    networks:
      - hermes-net

  hermes-webui:
    image: ghcr.io/nesquena/hermes-webui:latest
    container_name: hermes-webui
    depends_on:
      - hermes-agent
    ports:
      # Expose on localhost only. Remove 127.0.0.1: to expose on all interfaces
      # (set HERMES_WEBUI_PASSWORD if doing so).
      - "127.0.0.1:8787:8787"
    volumes:
      # Same hermes home as the agent — shares config, sessions, state
      - hermes-home:/home/hermeswebui/.hermes
      # Agent source mounted where docker_init.bash expects it.
      # Mounted read-only — the WebUI only reads this volume to install
      # the agent's Python dependencies at startup (`uv pip install`).
      # Read-only enforces that defence-in-depth at the kernel layer.
      - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
      # Workspace directory — browse and edit files from the WebUI.
      # Adapt the host path to your project directory.
      # ${HOME} is used rather than `~` so the default resolves the same way
      # across Linux, macOS, WSL2, and Docker Desktop on Windows.
      - ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
    environment:
      - HERMES_WEBUI_HOST=0.0.0.0
      - HERMES_WEBUI_PORT=8787
      - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
      # Let the WebUI health/status checks reach the gateway container over
      # the compose network. Cron listing is local, but scheduled ticking and
      # the Tasks/System gateway pill need a reachable gateway URL (#4483).
      - HERMES_API_URL=http://hermes-agent:8642
      # Match your host user's UID/GID for correct file permissions.
      # Run `id -u` and `id -g` to find your values.
      # On macOS, UIDs start at 501 (not 1000) — set these in a .env file:
      #   echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
      - WANTED_UID=${UID:-1000}
      - WANTED_GID=${GID:-1000}
      # Forward the API server key so the WebUI's gateway health probe can
      # authenticate (must match the agent's API_SERVER_KEY above).
      - HERMES_WEBUI_GATEWAY_API_KEY=${API_SERVER_KEY:-}
      # Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env
      # (required if you expose the port beyond 127.0.0.1).
      - HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-}
      # NOTE: When using bind-mount volumes shared across containers, ALL containers
      # that write to the same host directory must run as the same UID/GID.
      # If hermes-agent initialises the state dir as root (UID 0), hermes-webui
      # will get a PermissionError accessing those paths — including a crash on every
      # HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID
      # to match the agent container's UID, or use a named Docker volume (preferred).
      # Bind-mount permission handling for the WebUI (fixes #1389, #1399).
      # NOTE: WebUI's HERMES_HOME_MODE is a credential-file threshold (allow
      # group bits on .env/.signing_key/etc.), DIFFERENT from the agent's
      # which applies to the HERMES_HOME directory itself. 0640 is correct
      # for the WebUI; do NOT copy this value to the agent service block.
      # - HERMES_SKIP_CHMOD=1
      # - HERMES_HOME_MODE=0640
    restart: unless-stopped
    networks:
      - hermes-net

networks:
  hermes-net:
    driver: bridge

volumes:
  # IMPORTANT — upgrading the agent image:
  #   The `hermes-agent-src` volume is initialised from the agent image's
  #   `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
  #   later runs — even after `docker pull` of a newer agent image. After
  #   upgrading the agent image, run:
  #
  #     docker compose -f docker-compose.three-container.yml down
  #     docker volume rm _hermes-agent-src
  #     docker compose -f docker-compose.three-container.yml pull
  #     docker compose -f docker-compose.three-container.yml up -d
  #
  #   The full procedure (and why) is documented in docs/docker.md.
  hermes-home:
  hermes-agent-src:

Technical Documentation: nesquena/hermes-webui

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

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

Setup Hermes so you can access it natively on every device:

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.


Contents


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 docs/why-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 docs/why-hermes.md for the full side-by-side.


Quick start

Run the repo bootstrap:

bash
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
cd hermes-webui
python3 bootstrap.py

Or keep using the shell launcher:

bash
./start.sh

For self-hosted VM or homelab installs, ctl.sh wraps the common daemon lifecycle commands without requiring fuser or pkill:

bash
./ctl.sh start              # background daemon, PID at ~/.hermes/webui.pid
./ctl.sh status             # PID, uptime, bound host/port, log path, /health
./ctl.sh logs --lines 100   # tail ~/.hermes/webui.log
./ctl.sh restart
./ctl.sh stop

ctl.sh start runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to ~/.hermes/webui.log, and respects .env plus inline overrides such as HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start.

Stopping the server. Each launch method has its own stop path because only ctl.sh start writes a PID file (~/.hermes/webui.pid):

Launch method How to stop
python3 bootstrap.py Ctrl-C in the terminal (runs in the foreground)
./ctl.sh start ./ctl.sh stop (sends SIGTERM, waits, then SIGKILL)
Detached bootstrap.py (no --foreground) or ./start.sh Find the PID via lsof -i :8787 (or ss -tlnp) and kill it

./ctl.sh stop cannot stop a server launched by bootstrap.py or start.sh directly — it only manages processes it started itself.

How chat runs by default. WebUI runs the Hermes agent in-process, reading
your HERMES_HOME config directly. It does not connect to an external
Hermes/agent OpenAI-compatible API server to run chat. HERMES_API_URL is only
read by the Tasks/cron health probe and does not route chat.

Two options if you run an external endpoint:

  1. Use its models as a chat provider (supported today): add it in
    Settings → Providers as a custom OpenAI-compatible provider with
    base_url = http://127.0.0.1:8642/v1 and your bearer token.
  2. Route chat through a Hermes Gateway API server (supported today via
    HERMES_WEBUI_CHAT_BACKEND=gateway): see docs/advanced-chat-setup.md.
    Full agent-loop delegation is not yet shipped; tracked in #1925.

Advanced: dynamic recall prefill & Gateway-backed chat

Two optional, self-hosted-deployment features — attaching dynamic session-recall prefill to browser turns (Joplin/Obsidian/Notion/llm-wiki routers), and routing browser chat through a running Hermes Gateway — are documented in docs/advanced-chat-setup.md. Most users need neither.

The bootstrap will:

  1. Detect Hermes Agent and, if missing, attempt the official installer (curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash).
  2. Find or create a Python environment with the WebUI dependencies.
  3. Start the web server and wait for /health.
  4. Open the browser unless you pass --no-browser.
  5. 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.
For Windows / WSL auto-start at login, see docs/wsl-autostart.md.

A community-maintained native Windows setup is documented at @markwang2658/hermes-windows-native-guide (companion setup repo: @markwang2658/hermes-windows-native). Notes from the community report in #1952:

  • Memory: community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration).
  • What works: chat, workspace browser, session management, all themes.
  • Known limitations: some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively.
  • Native Windows setup: install Python 3.11+, then from the hermes-agent root in PowerShell: python -m venv venv → pip install -r requirements.txt → pwsh .\start.ps1 (it auto-discovers venv\Scripts\python.exe).
  • WSL2 relationship: not a prerequisite — a WSL2-built venv (venv/bin/python, ELF) isn't invokable by native Windows Python, so use the native setup above. WSL2 stays useful as a parallel install if you want the full bootstrap.py + Linux runtime.

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.
For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see docs/onboarding.md.
If an AI assistant is helping with install, reinstall, bootstrap, provider setup, or first-run support, have it read docs/onboarding-agent-checklist.md before running commands or inspecting logs.


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, Xiaomi MiMo, 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
  • Detached child results are delivered to the parent as internal wakeups. Their raw prompts and completion toasts do not appear as messages from the user; the parent can summarize the results in its own reply. Ordinary background-process wakeups remain visible. A completed parent reply does not, by itself, cancel or suppress outstanding children; a later result may still start a follow-up turn.
  • 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 and are stored outside the active workspace by default (~/.hermes/webui/attachments/<session_id>/, or HERMES_WEBUI_ATTACHMENT_DIR/<session_id>/ when configured)
  • 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)
  • Bare HTTP(S) URLs are auto-linked without absorbing adjacent CJK prose; Unicode IDN host separators and raw-CJK IRI paths remain linkable
  • 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)
  • Pinned-conversation limits apply separately to each session's owning profile; root/default aliases share a limit. Unpin an empty session before switching its profile through chat or /goal.
  • Archive sessions (hide without deleting, toggle to show)
  • Session projects -- named groups with colors for organizing sessions; delegated subagent sessions have no project of their own and follow their nearest ancestor's project in the project filter and the Unassigned chip; forks and other child sessions keep their own project, so a fork moved to "No project" stays Unassigned
  • Project bindings -- right-click a project chip and pick Project settings… to pin a default workspace (or several workspaces, with one marked default) and a model. The chip's + (and New Chat while the project filter is active) then opens a session already in that workspace/model. The reasoning-effort binding stays in the API (POST /api/projects/bind) but is dormant: the dialog no longer offers the row, and nothing applies a stored value to a new session any more — the only mechanism would be the profile-wide reasoning-effort preference (the same agent.reasoning_effort setting the /reasoning command writes), so a row inside a per-project dialog read as per-project while silently moving the whole profile's effort; it comes back with per-session effort (#7881)
  • 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
  • Create a public read-only share link for the active conversation from the Control Center; shared pages show a sanitized transcript snapshot without workspace, profile, or live controls
  • 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 /usage command)

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
  • Chat links using workspace://path/to/file open files in the right-side preview pane
  • 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)
  • Dismissing the workspace panel is respected: resizing the viewport or showing the
    on-screen keyboard no longer force-reopens the panel after you close it (#6710)
  • Previews report their real load outcome -- a broken source is reported as a
    failure instead of a silent success -- and a response that never settles is
    released by a bounded wait so the panel still opens; the source keeps loading
    and a late failure is surfaced in the status line (#6710)
  • 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.yaml at 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_PASSWORD env var or Settings panel
  • Installed PWAs work best with WebUI's own password. Reverse proxies are supported, but proxy basic auth can block the service-worker update fetches an installed app needs and leave it on a blank screen after an update; see docs/troubleshooting.md for recovery steps.
  • Optional passkeys/WebAuthn -- register from Settings -> System after signing in with a password; the login page only shows passkey sign-in after at least one passkey exists
  • After registering at least one passkey, Settings -> System can remove the password and keep passkey-only sign-in enabled. Password auth remains the bootstrap/recovery path until you choose to go passwordless; passkeys are same-origin and stored locally in the WebUI state directory
  • Optional native OIDC login for WebUI sessions -- configure webui_oidc.issuer, client_id, allow_claim, and allow_values in config.yaml, or set the matching HERMES_WEBUI_OIDC_* environment variables. OIDC stays disabled until all four are present, and startup prints a warning if the config is partial.
  • Native OIDC stores the PKCE/state nonce flow in process memory. That works for the shipped single-process server, and it also works behind a load balancer when callbacks stay sticky to the same WebUI instance. Multi-instance deployments need session affinity, or the callback can land on a different process and fail state validation.
  • 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

  • Appearance is split into two axes: Theme (system, dark, light) and Skin
    (default, ares, mono, slate, poseidon, sisyphus, charizard,
    sienna, catppuccin, nous, geist-contrast / Geist Contrast)
  • Switch via Settings -> Appearance (instant live preview) or /theme
  • Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
  • Skins use data-skin plus CSS variables; dark mode resolves through the
    .dark class, not a data-theme custom-theme axis — 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
  • Send-key on touch devices: plain Enter inserts a newline on phones (iPhone/iPod, Android phones) and on tablets / iPadOS / touch-capable Macs that only expose a coarse pointer (no attached hardware keyboard), matching the software keyboard's return key. Devices that report a fine pointer (for example, a tablet with a hardware keyboard) keep the configured physical-keyboard send-key behavior. The configured shortcut and the Send button remain available for sending.
  • Show/hide CLI sessions toggle (enabled by default)
  • Token usage display toggle (off by default, also via /usage command)
  • Task list in chat toggle (off by default) -- a collapsible task strip pinned at the top of the chat area, summarising the current session's todos. While it is on, the sidebar Todos panel is hidden and the workspace-panel Todos toggle is disabled with an explanation, so the same list is never shown twice; switch it back off in Settings -> Appearance to restore the sidebar panel and re-enable the workspace toggle.
  • 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 badges scoped to the active profile on the Tasks tab and session sidebar
  • Background agent error alerts -- banner when a non-active session encounters an error
  • Approval and clarification browser alerts notify once per pending owner when its session is not actively viewed. Local approval dismissal hides attention without resolving the prompt; resolution, replacement, and cancellation retire that owner's alert state. Denied or failed notification delivery can retry while the prompt remains pending.

Slash commands

  • Type / in the composer for autocomplete dropdown
  • Plain skills match case-insensitive keywords in their name or description; built-in, agent/plugin, and bundle commands keep prefix matching and take precedence over a same-slug skill
  • 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

Model aliases in `/model`

/model <alias> accepts any alias configured for the profile, in either of Hermes's two formats:

yaml
model_aliases:                     # canonical
  sol:
    model: gpt-5.6-sol
    provider: openai-codex

model:
  provider: openrouter
  aliases:                         # legacy
    sol: openai-codex/gpt-5.6-sol  # provider-qualified
    fast: gpt-4                    # unqualified

Alias resolution follows the format of the alias:

  • A canonical model_aliases entry and a provider-qualified legacy target name their own
    route. The target is authoritative, so /model sol selects that provider even when a
    same-named model exists on another provider. A canonical entry also takes precedence over a
    legacy entry with the same name.
  • An unqualified legacy target names only a model, so it keeps the ordinary lookup: the
    active provider first, then the normal fuzzy match. This is the behavior /model had before,
    and it is unchanged.

Aliases that carry their own endpoint or credentials are resolved server-side; the browser only
receives the model, the provider id, and an opaque route id, never a base URL or key.
On the in-process backend, an alias with its own base_url follows Hermes's direct-alias
credential rules: its declared api_key/key_env wins; otherwise only a credential resolved
for that endpoint's own host is sent (for example an OpenRouter key to openrouter.ai), never
the provider label's key to an unrelated host. The alias's provider still selects its wire protocol.

Gateway and runner chat support provider-only aliases by sending the resolved target model
and provider, not the alias name: their runtime does not share WebUI's alias registry. Aliases
that explicitly declare base_url, api_key, or key_env need the in-process backend. External
chat starts, wakeups, Gateway regeneration, and Gateway goal kickoffs reject them with HTTP 400 and
reason: model_alias_requires_in_process_backend before dispatch; a new Gateway goal is not
set. Runner regeneration remains unsupported (HTTP 409), regardless of alias selection.
Ambient credentials resolved for a provider-only alias do not count as alias overrides.
The session retains its target model and profile-bound opaque route id, including Gateway
success/error writeback, so a later backend switch can still resolve the original alias.

A session stores that opaque route id, not the endpoint, so an alias that is later deleted or renamed
leaves the session pointing at a route nothing owns. That send fails closed — on every backend, the
in-process worker, the gateway and the runner alike — with a controlled "model alias unavailable"
error instead of quietly falling back to another provider; pick the model again to store a live route.

With HERMES_WEBUI_RUNTIME_ADAPTER=runner-local, /goal <text> returns HTTP 501
(status: unsupported) without changing an existing goal or starting a run. The
runner contract does not yet provide atomic goal replacement and kickoff; WebUI
never substitutes local goal execution. /goal status, pause, resume, and
clear delegate to the runner when supported. Legacy-direct and legacy-journal
goal kickoff behavior is unchanged for supported routes. Normal runner chat supports provider-only model aliases.

Server-initiated turns retain the pre-session stale-Agent-runtime barrier. It
runs before loading the session; named-profile alias and Gateway routing happen
only after admission. A stale default local runtime can therefore reject a wakeup
before the session's named-profile Gateway ownership is known.

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; hidden while the in-chat task list toggle is on, which also disables the workspace-panel Todos tab
  • Insights -- token, cost, and skill-usage analytics; period presets (7/30/90/365 days) plus a Custom range with start/end date pickers
  • 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

Configuration & access

start.sh auto-detects almost everything; the subsections below cover the knobs for when it can't, and how to reach the UI remotely.

What start.sh discovers automatically

Thing How it finds it
Hermes agent dir HERMES_WEBUI_AGENT_DIR, then valid authoritative run_agent.py checkouts, the hermes launcher, the selected Python, valid fallback checkouts, and finally marker-only roots
Python executable HERMES_WEBUI_PYTHON, Agent venv, interpreter that proved the Agent root, .venv in this repo, then system Python
State directory HERMES_WEBUI_STATE_DIR env, then $HERMES_HOME/webui (Windows default %LOCALAPPDATA%\hermes\webui, POSIX default ~/.hermes/webui)
Default workspace HERMES_WEBUI_DEFAULT_WORKSPACE env, then ~/workspace, then state dir
Port HERMES_WEBUI_PORT env or first argument, default 8787

Before it installs the agent or writes any state, the bootstrap binds the resolved host and port as a preflight. If another service already holds the port, it stops right there, names the address it could not bind, and prints a free alternative to pass to ./start.sh or set as HERMES_WEBUI_PORT in .env. The running service and your existing configuration are left untouched. When the listener on that port is a healthy WebUI -- the instance you already started -- bootstrap reports it ready and leaves it alone, browser included; only a foreign service gets the conflict message.

If discovery finds everything, nothing else is required.


Overrides (only needed if auto-detection misses)

bash
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

Auto-install requires the selected Agent to match HERMES_WEBUI_AGENT_DIR or the Agent under HERMES_HOME, and still checks ownership and permissions.

Or inline:

bash
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 source or installed module root
HERMES_WEBUI_PYTHON auto-discovered Python executable
HERMES_WEBUI_HOST 127.0.0.1 Bind address (0.0.0.0 for all IPv4, :: for all IPv6, ::1 for IPv6 loopback)
HERMES_WEBUI_PORT 8787 Port
HERMES_WEBUI_STATE_DIR $HERMES_HOME/webui (Windows default %LOCALAPPDATA%\hermes\webui, POSIX default ~/.hermes/webui) Where sessions and state are stored. Note (upgrade): the default now follows HERMES_HOME — if you previously relocated HERMES_HOME to a non-default base without setting HERMES_WEBUI_STATE_DIR, your WebUI state now resolves to $HERMES_HOME/webui instead of the old platform-default ~/.hermes/webui. To keep using the old location, set HERMES_WEBUI_STATE_DIR to it (or move the directory). Installs with HERMES_HOME unset or at the default base are unaffected.
HERMES_WEBUI_SETTINGS_FILE <state dir>/settings.json Optional path for this instance's WebUI settings file (sessions, workspaces and projects stay in the state directory). Read once at startup, so restart after changing it
HERMES_WEBUI_DEFAULT_WORKSPACE ~/workspace Default workspace
HERMES_WEBUI_DEFAULT_MODEL (provider default) Optional model override; leave unset to use the active Hermes provider default
HERMES_WEBUI_PASSWORD (unset) Set to enable password authentication
HERMES_WEBUI_CSP_CONNECT_EXTRA (unset) Optional space-separated http(s):// or ws(s):// origins to append to the enforced and report-only CSP connect-src directives for trusted reverse-proxy, tunnel, or extension sidecar deployments
HERMES_WEBUI_CSP_IMG_EXTRA (unset) Optional space-separated http(s):// image origins (wildcard subdomain and port allowed), or the bare https: / http: scheme, added to the CSP img-src. Remote images are blocked by default so an assistant reply cannot make the browser fetch an arbitrary URL; blocked images render as an inert "Open image" link. Use this to show images from a trusted CDN inline, e.g. an image-generation provider
HERMES_WEBUI_SSE_CHUNKED (unset) Set truthy (1/true/yes/on) to send SSE with Transfer-Encoding: chunked. Needed behind buffering reverse proxies (e.g. jupyter-server-proxy) that otherwise buffer the whole stream; harmless but unnecessary for directly-served deployments
HERMES_WEBUI_EXTENSION_DIR (unset) Optional local directory served at /extensions/; must point to an existing directory before extension injection is enabled
HERMES_WEBUI_EXTENSION_MANIFEST (unset) Optional relative JSON manifest inside HERMES_WEBUI_EXTENSION_DIR listing bundled scripts/styles to inject; see WebUI Extensions
HERMES_WEBUI_EXTENSION_SCRIPT_URLS (unset) Optional comma-separated same-origin script URLs to inject; appended after manifest scripts; see WebUI Extensions
HERMES_WEBUI_EXTENSION_STYLESHEET_URLS (unset) Optional comma-separated same-origin stylesheet URLs to inject; appended after manifest stylesheets; see WebUI Extensions
HERMES_HOME Windows: %LOCALAPPDATA%\hermes; POSIX: ~/.hermes Base directory for Hermes state (affects all paths)
HERMES_CONFIG_PATH $HERMES_HOME/config.yaml Path to Hermes config file
HERMES_WEBUI_SERVER_CWD (unset) Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable
HERMES_WEBUI_VISIBLE_SESSION_LIMIT 20 Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default. Values above 200 are clamped. Resolved before profile init, so a profile .env cannot override it
HERMES_WEBUI_AGENT_CACHE_MAX 25 Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads)
HERMES_WEBUI_SESSIONS_MAX 100 Legacy operator override for the max compact Session objects held in the in-memory LRU. Prefer the webui.sessions_cache_max key in config.yaml (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access

Extension deployments can inspect sanitized, authenticated diagnostics at GET /api/extensions/status; see WebUI Extensions.


Nix flake and NixOS module

Hermes WebUI has a Nix flake package and a NixOS service module so you can run it declaratively.

Install the latest package with:

bash
nix shell github:nesquena/hermes-webui#default

Use this flake input in your system configuration:

nix
inputs.hermes-webui.url = "github:nesquena/hermes-webui";

Then add the module and configure it in nixosModules:

nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    hermes-agent.url = "github:NousResearch/hermes-agent";
    hermes-webui.url = "github:nesquena/hermes-webui";
  };

  outputs = { self, nixpkgs, hermes-agent, hermes-webui, ... }: {
    nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
      modules = [
        hermes-agent.nixosModules.default
        hermes-webui.nixosModules.default
        ({ pkgs, ... }: {
          services.hermes-agent.enable = true;
          services.hermes-webui = {
            enable = true;
            host = "127.0.0.1";
            port = 8787;
            stateDir = "/var/lib/hermes-webui";
            user = "hermes";
            group = "hermes";
            hermesHome = "/var/lib/hermes/.hermes";
            agent.package = hermes-agent.packages.${pkgs.stdenv.hostPlatform.system}.default;
            environmentFiles = [ "/run/secrets/hermes-webui.env" ];
          };
        })
      ];
    };
  };
}

The module defaults to 127.0.0.1. Set host = "0.0.0.0" and openFirewall = true only when you want direct network access, and pair that with auth, for example HERMES_WEBUI_PASSWORD via environmentFiles.

The published Hermes Agent package exposes passthru.hermesVenv, so the module derives HERMES_WEBUI_PYTHON from its interpreter. Bootstrap then locates the installed run_agent.py through that interpreter without importing Agent code and exports its parent as HERMES_WEBUI_AGENT_DIR. Packages may expose passthru.hermesAgentDir as a direct path instead. Use agent.dir and agent.python as explicit overrides for custom package layouts.

When WebUI reads shared Hermes Agent state, run the service as a user that can already read that state. For a co-located Hermes Agent service, set user and group to the agent service account; the module only creates the default hermes-webui account and never changes ownership of an existing hermesHome.

The module maps directly onto existing WebUI environment variables, including:
HERMES_WEBUI_HOST, HERMES_WEBUI_PORT, HERMES_WEBUI_STATE_DIR, HERMES_HOME, HERMES_WEBUI_AGENT_DIR, and HERMES_WEBUI_PYTHON.

Set environmentFiles for secrets like API keys. Protected WebUI runtime keys from the module are rejected there, so keep host, port, state, and agent wiring in the module options. Keep reverse proxy and TLS configuration in your surrounding deployment module because those details are deployment-specific.

Remote access (SSH tunnel, Tailscale, phone)

The server binds to 127.0.0.1 by default. To reach it from another machine,
use an SSH tunnel (ssh -N -L 8787:127.0.0.1:8787 user@host, which start.sh
prints for you over SSH) or, on a single-operator or access-restricted tailnet,
use the preferred Tailscale Serve
flow, which keeps WebUI on loopback behind tailnet-only HTTPS. Direct access to
http://<server-tailscale-ip>:8787 with HERMES_WEBUI_HOST=0.0.0.0 and
HERMES_WEBUI_PASSWORD is a fallback when Serve is unavailable. Full setup and
the community ARM64-Android field report: docs/remote-access.md.

Manual launch (without start.sh)

If you prefer to launch the server directly:

bash
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:

bash
curl http://127.0.0.1:8787/health

Docker

Pre-built images (amd64 + arm64) are published to GHCR on every release.

For a comprehensive setup guide covering all 3 compose files, common failure modes, and bind-mount migration, see docs/docker.md. The README covers the 5-minute happy path.

5-minute quickstart (single container)

The simplest setup: one WebUI container that runs the agent in-process.

bash
git clone https://github.com/nesquena/hermes-webui
cd hermes-webui
cp .env.docker.example .env
# Edit .env if your host UID isn't 1000 (e.g. macOS where UIDs start at 501)
docker compose up -d
# Open http://localhost:8787

Run Compose as the user who owns your Hermes home. sudo docker compose up -d can make ${HOME} expand to the root user's home, so Docker mounts the wrong .hermes directory instead of your real ~/.hermes and the WebUI starts with config.yaml (not found, using defaults). Prefer adding your user to the Docker group and running docker compose up -d; if you must use sudo, set absolute paths first, for example HERMES_HOME=/home/you/.hermes HERMES_WORKSPACE=/home/you/workspace sudo -E docker compose up -d, then verify with docker compose config.

The container auto-detects your UID/GID from the mounted ~/.hermes volume so files written by the agent stay readable by you on the host.

To enable password protection (required if you expose the port outside 127.0.0.1):

bash
echo "HERMES_WEBUI_PASSWORD=change-me-to-something-strong" >> .env
docker compose up -d --force-recreate

Manual `docker run` (no compose)

bash
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 \
  -v ~/workspace:/workspace \
  -p 127.0.0.1:8787:8787 \
  ghcr.io/nesquena/hermes-webui:latest

Build locally

bash
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 \
  -v ~/workspace:/workspace \
  -p 127.0.0.1:8787:8787 \
  hermes-webui

Multi-container setups

If you want the agent and WebUI in separate containers (for isolation, or because you're already running an agent gateway elsewhere):

bash
# Agent + WebUI
docker compose -f docker-compose.two-container.yml up -d

# Agent + Dashboard + WebUI
docker compose -f docker-compose.three-container.yml up -d

Both compose files use named Docker volumes by default, which solves the UID/GID problem by construction. If you need bind mounts to share an existing host directory, see docs/docker.md for the full migration recipe.

Known limitation (#681): in the two-container setup, tools triggered from the WebUI run in the WebUI container, not the agent container. If you need git/node/etc. on the WebUI's filesystem, either use the single-container setup, extend the WebUI Dockerfile, or use the community all-in-one image.

Source boundary note (#2453): the multi-container setup mounts hermes-agent-src read-only into the WebUI by default. This prevents WebUI-side source rewrites but is still an implementation-coupling bridge, not a stable Agent API boundary. See docs/rfcs/agent-source-boundary.md for the current source/API decoupling inventory.

Common failure modes

Symptom Likely cause Fix
PermissionError at startup UID mismatch on bind mount Set UID=$(id -u) in .env
.env: permission denied (#1389) fix_credential_permissions() enforced 0600 Set HERMES_SKIP_CHMOD=1 in .env
Workspace appears empty UID mismatch on /workspace mount Set UID=$(id -u) in .env
git: command not found in chat Two-container architectural limit (#681) Use single-container or extend Dockerfile
WebUI can't find agent source hermes-agent-src volume misconfigured Use the named volumes from compose files as-is
Podman shared .hermes fails Podman 3.4 keep-id limitation Use Podman 4+ or single-container
Host API at localhost fails from WebUI Container localhost means the container, not your host (#3012) Use http://host.docker.internal: on Docker Desktop, or http://host.containers.internal: on Podman
WebUI can't see ~/.hermes after sudo docker compose ${HOME} expanded to the root user's home (#3006) Run Compose as your user, or pass absolute HERMES_HOME/HERMES_WORKSPACE with sudo -E

For the deep dive on each of these, see docs/docker.md.

Note: By default, Docker Compose binds to 127.0.0.1 (localhost only).
To expose on a network, change the port to "8787:8787" in docker-compose.yml
and set HERMES_WEBUI_PASSWORD to enable authentication.


Running tests

Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
Use the repo test runner so local runs do not accidentally use an unsupported
system Python. It creates/uses .venv with Python 3.11, 3.12, or 3.13 and
installs the dev test dependencies from requirements-dev.txt when missing.

bash
cd hermes-webui
./scripts/test.sh

Pass normal pytest arguments after the script for focused runs:

bash
./scripts/test.sh tests/test_regressions.py -v

Or seed the repo .venv from an explicit supported base interpreter:

bash
HERMES_WEBUI_TEST_PYTHON=/path/to/python3.12 ./scripts/test.sh tests/ -v

The override selects the Python used to create or rebuild .venv; dependencies
are still installed into the repo-local virtual environment, not into the
system/Homebrew interpreter.

Tests run against an isolated server with a separate state directory.
Production data and real cron jobs are never touched. Current snapshot:
~11,500 tests collected across ~1,150 test files, run in CI on Python 3.11,
3.12, and 3.13 (3 parallel shards each).


Architecture

No build step, no framework, no bundler — a Python standard-library HTTP server
and vanilla JS. The backend lives in api/, the frontend in static/.

Backend (api/)

text
server.py         HTTP routing shell + auth middleware
api/
  auth.py         Optional password authentication, signed cookies, passkeys
  config.py       Discovery, globals, model detection, reloadable config
  helpers.py      HTTP helpers, security headers
  models.py       Session model + CRUD + CLI/state.db bridge
  onboarding.py   First-run onboarding wizard, OAuth provider support
  profiles.py     Profile state management, hermes_cli wrapper
  routes.py       All GET + POST route handlers (if/elif dispatch, no decorators)
  state_sync.py   /insights sync — message_count to state.db
  streaming.py    SSE engine, run_agent, cancellation, compression
  updates.py      Self-update check and release notes
  upload.py       Multipart parser, file upload handler
  workspace.py    File ops, workspace helpers, git detection

Frontend (static/)

text
index.html        HTML template
style.css         All CSS incl. mobile responsive, themes + skins
ui.js             DOM helpers, renderMd, tool cards, context indicator
workspace.js      File preview, file ops, git badge, central api() fetch wrapper
sessions.js       Session CRUD, collapsible groups, search, reload recovery
messages.js       send(), SSE handlers, live streaming, session recovery
panels.js         Cron, skills, memory, profiles, settings (Control Center)
commands.js       Slash command autocomplete
boot.js           Mobile nav, voice input, theme/skin boot, bfcache handler

Tests + packaging

text
tests/            Pytest suite (~11,500 tests; isolated server/state fixtures)
pyproject.toml    Standard build metadata plus the Ruff lint gate; checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh
Dockerfile        python:3.12-slim container image
docker-compose.yml  Compose with named volume and optional auth
.github/workflows/  CI: ruff + sharded pytest, browser smoke, Docker smoke,
                    multi-arch Docker build + GitHub Release on tag

State lives outside the repo at ~/.hermes/webui/ by default
(sessions, workspaces, settings, projects, last_workspace). Override with HERMES_WEBUI_STATE_DIR.
Full design notes and the endpoint catalog are in ARCHITECTURE.md.


Compatibility

The version shown in the WebUI runtime status is the WebUI version only (build/image/tag currently running). It is not a full compatibility map.

The WebUI is still coupled to Hermes Agent internals for runtime execution, provider/model access, and state/schema usage until the stable agent boundary work in #1925 and #2491 land. In practice, the WebUI imports Agent modules directly (api/config.py, api/providers.py, api/streaming.py) and reads Agent state layout directly, so version skew can cause import or behavior drift.

Compatibility policy

  • WebUI release branches are tested against the matching Hermes Agent release available at that WebUI release time.
  • Upgrade both together: upgrade or pin WebUI and hermes-agent together (same release train/version/date), especially before enabling production traffic.
  • Running pinned older/newer combinations is untested and unsupported until the stable API boundary work in #1925 / #2491 is in place.
  • Record the full hermes-agent + hermes-webui versions in issue reports when upgrade mismatches are suspected.

Docker users: pin both image tags (or corresponding pinned source revisions) rather than using latest on one side and a fixed tag on the other. When upgrading the multi-container setup, follow the agent-image upgrade procedure in docs/docker.md (which requires dropping the hermes-agent-src volume before recreating). The current source-boundary status is tracked in docs/rfcs/agent-source-boundary.md.


Docs

Start here

  • docs/why-hermes.md — why Hermes, the mental model, and a detailed comparison to Claude Code / Codex / OpenCode / Cursor
  • docs/onboarding.md — first-run wizard, provider setup, local model server Base URLs, and safe re-runs
  • docs/troubleshooting.md — diagnostic flows for common failures (e.g. "AIAgent not available")

Using & customizing

  • THEMES.md — theme + skin system, custom theme guide
  • docs/workspace-git.md — the workspace Git controls
  • docs/kanban.md — the Kanban board's status flow: who may set which column, how tasks enter and leave Scheduled, and the parent re-gating on Scheduled/Blocked → Ready
  • docs/EXTENSIONS.md — administrator-controlled WebUI extension injection

Deploying & operating

Contributing & design

  • CONTRIBUTING.md — contribution style, PR expectations, and local verification
  • ARCHITECTURE.md — system design, all API endpoints, implementation notes
  • TESTING.md — manual browser test plan and automated coverage reference
  • DESIGN.md — design tokens and the calm-console direction
  • docs/UIUX-GUIDE.md — UI/UX principles sourced from the design docs and visual inventories
  • docs/sse-streams.md — cross-client SSE endpoint reference: session streaming, gateway SSE probe scope, heartbeats, and proxy behavior
  • docs/architecture/transcript-auto-follow-scroll.md — transcript auto-follow contract: pinned/unpinned state model, input-tail re-pinning, reader-resume rules, and the transcript's overscroll suppression rationale
  • docs/CONTRACTS.md — project contract/RFC/design index for contributors and agents
  • docs/rfcs/README.md — RFC index for larger architecture and durability proposals

Release history & plan


Contributors

Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly, absorbed into a batch release, or salvaged from a larger proposal — shapes the project, and we're grateful to everyone who has taken the time to contribute.

Over 326 contributors have shipped code that landed in a release tag. The full, continuously-updated credit roll — including everyone with one or two PRs and the special-thanks roll for design and architectural work — lives in CONTRIBUTORS.md. A snapshot of the most prolific contributors:

Top contributors (by PR count, including absorbed/batch-released work)

# Contributor PRs First → latest release
1 @rodboev 336 v0.51.223 → v0.51.893
2 @franksong2702 301 v0.49.3 → v0.51.893
3 @Michaelyklam 157 v0.50.240 → v0.51.198
4 @ai-ag2026 121 v0.50.279 → v0.51.835
5 @bergeouss 80 v0.48.0 → v0.51.703
6 @AJV20 57 v0.51.93 → v0.51.346
7 @dso2ng 43 v0.50.227 → v0.51.578
8 @starship-s 28 v0.50.123 → v0.51.763
9 @Sanjays2402 27 v0.50.292 → v0.51.484
10 @allenliang2022 24 v0.51.185 → v0.51.869

See CONTRIBUTORS.md for the full ranked list of all 326 contributors — the 3+ PR tables, the 1–2 PR roll, and the special-thanks notes for design and architectural contributions.

Notable contributions

@franksong2702 — Most prolific external contributor (180 PRs, v0.49.3 → v0.51.384)
Across the longest tenure of any external contributor: the session title guard (#301), breadcrumb workspace navigation (#302), embedded workspace terminal (#1099), worktree-backed session creation (#2053), onboarding documentation (#2052), composer footer container queries, streaming-session sidebar exemption (#1327), session sidecar repair, cron output preservation (#1295), profile default workspace persistence, manual /compress async start/status endpoints (#2128), worktree status surface (#2109) + guarded remove (#2156) for the lifecycle umbrella #2057, session post-render dedup (#2166), native-WebUI fast path (#2170), tail-window response trim (#2171), stale-stream guard extension (#2158), CSP report collector (#2160), and a long tail of polish across mobile/responsive, the session sidebar, and the workspace state machine.

@Michaelyklam — Most prolific contributor of recent releases (118 PRs, v0.50.240 → v0.51.198)
Production Docker hardening (#1921, drops sudo-capable staging user), profile-scoped skills endpoints (#1903), gateway PID resolution under profile-scoped HERMES_HOME (#1901), profile-aware AIAgent cache (#1898/#1904), backslash LaTeX delimiters (#1848), Codex quota error surfacing (#1770), shell-route HTML 503 (#1836), stale Kanban client recovery (#1828), context auto-compression toast lifetime (#1988), /goal command (#1866), Kanban detail-view scrolling (#1916), CLI session tool metadata preservation (#1778), Traditional Chinese kanban locale backfill (#1979), v0.51.51 mobile Insights bucketing/layout (#2120/#2121), Hermes run adapter RFC (#2105 for #1925), fork-from-here absolute index (#2198 for #2184), opencode-go custom-provider overlap routing (#2204 for #1894).

@rodboev — Windows / cross-platform correctness + test reliability (83 PRs, v0.51.223 → v0.51.384)
A broad, sustained sweep with a strong focus on making the project work outside Linux: the ctl.sh Windows process-tree-kill fix (#3670), the local Windows full-suite signal/orphan-process work, slash-command autocomplete polish, and a long tail of frontend and infrastructure fixes shipped across dozens of release batches.

@bergeouss — Provider management UI + Docker hardening (70 PRs, v0.48.0 → v0.51.385)
Provider management UI for adding/editing custom providers from Settings, OAuth provider status detection (#1552), two-container Docker setup, profile isolation hardening (per-profile .env secrets), the bulk of what users see when they touch Settings → Providers, Reveal-in-Finder context menu (#1551), gateway status card (#1552), auto-assign session to active project filter (#1550), "What's new?" link in update banner (#1549), OpenRouter free-tier live fetch (#1548), credential pool 401 self-heal (#1553), inline provider chip + group model count in model picker (#1644).

@ai-ag2026 — Session recovery + audit infrastructure (75 PRs, v0.50.279 → v0.51.367)
Autonomous-AI contributor (Hermes Agent-driven) focused on durability: state.db-backed sidecar reconciliation (#2041), orphan .json.bak recovery on startup (#2035), read-only session recovery audit endpoints (#2036, #2040), active run lifecycle in /health (#2039), crash-safe turn-journal RFC at docs/rfcs/turn-journal.md (#2042), append-only turn-journal helper (#2059), lifecycle events layer (#2062), Content-Security-Policy-Report-Only header (#2084), per-cron toast toggle (#2100), fork-session compression lineage isolation (#2014).

@dso2ng — Session lineage + diagnostics (30 PRs, v0.50.227 → v0.51.327)
/api/session/lineage-report/<sid> endpoint for bounded session graph diagnostics (#2012), stale Mermaid render error cleanup (#1337), session_source="fork" continuation-chain isolation (#2063), lazy lineage-report fetch on sidebar badge expand (#2130), and a long tail of frontend reliability fixes around session loading.

@jasonjcwu — Composer + transcript polish (16 PRs, v0.50.227 → v0.51.132)
Sidebar collapse via active-rail click (#2054, fuses #1884 + #1924), composer chip lightbox (#1758), title fixes for tool-heavy first turns, silent compress-status during session switch (#2185), concurrent-send loss fix (#2186), in-transcript steer message badges (#2187), and a string of frontend polish fixes.

@Jordan-SkyLF — Live streaming + UX polish (12 PRs, v0.50.18 → v0.51.66)
Original sprint of workspace fallback resolution, live reasoning cards (#366, #367, #394–#397), then a recent burst: manual "Refresh usage" button on the Provider quota card (#2150), cancelled-turn status classification (#2151), Firefox sidebar scroll stabilization (#2200), early provisional session titles (#2202), target-aware "What's new?" update-banner links (#2207), and MCP tools overflow fix in Settings (#2210).

@aronprins — v0.50.0 UI overhaul (PR #242, plus 9 follow-ups)
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. Plus chat transcript redesign (#587), sidebar declutter (#584), three-column layout refactor (#899), light/dark theme + accent skins (#627), and shared confirm()/prompt() dialog replacement (PR #251 extracted from #242).

@iRonin — Security hardening sprint (PRs #196–#204)
Six consecutive, focused security PRs: session memory leak fix (expired token pruning), CSP + 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. The kind of focused, high-quality security work that makes a self-hosted tool trustworthy.

@lucasrc — Auth-hardening trilogy (PRs #2191, #2192, #2193)
Three coordinated security PRs that all landed in v0.51.57: thread-safe login rate limiter with PBKDF2 key separation, password-hash cache invalidation on Settings save, and the full 64-char HMAC-SHA256 session signature with a backwards-compatible migration bridge. The kind of cleanly-decomposed security work that's reviewable as three independent pieces.

@LumenYoung — Streaming hot-path correctness (8 PRs, v0.51.47 → v0.51.99)
The original stale-stream writeback guard (#2136 — the bug class the next two releases extended), gateway-state alive-null classification (#2075), compression-banner anchor alignment (#2182), and context-progress ring auto-refresh on compression complete (#2188). Each PR opened a small surgical fix in one of the most fragile subsystems in the codebase.

@dobby-d-elf — Frontend reliability + motion polish (15 PRs, v0.51.38 → v0.51.161)
Workspace fallback on deleted directories (#2138), iPhone PWA bottom-scroll fix (#2143), the new "Activity: X tools" composer footer shimmer animation (#2203), and follow-up animation tuning (#2212).

@JKJameson — Composer + session polish (10 PRs)
Persistent composer draft per session (#1956), and a long tail of polish across the composer and session sidebar.

@gabogabucho — Spanish locale + onboarding wizard
Full Spanish (es) locale covering all UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch.

@deboste — Reverse-proxy auth + mobile responsive layout (PRs #3, #4, #5)
Three of the very first community PRs: fixed EventSource/fetch to use URL origin for reverse-proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.

@indigokarasu — Visual redesign proposal (PR #213)
A CSS-only redesign of the full UI — proper design tokens, 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 shaped the design language and theme architecture that shipped in v0.50.0.

@zenc-cp — Anti-hallucination guard for the ReAct loop (PR #133)
A three-layer approach (ephemeral anti-hallucination prompt, live token filtering, session-history cleanup) that the streaming pipeline still uses.

@Hinotoi-agent — Profile + session security (PRs #351, #2048)
Profile .env secret isolation fix (PR #351) preventing API key leakage between profiles, and session-import workspace validation (PR #2048) blocking a crafted-JSON file-read against /.

@Sanjays2402 — Endless-scroll + Start-jump race fix (PR #1949)
A generation-token + mutex pair fixing the v0.51.30 race between endless-scroll prefetch and Start-jump's _ensureAllMessagesLoaded. The naive same-flag-check approach (proposed in #1942 and #1962) was a no-op for the post-await race — Sanjays2402's fix was the correct shape.

@fxd-jason — Real-time approval + clarify via SSE (PRs #1350, #1355)
Replaced 1.5s HTTP polling with SSE long-connections for both approval and clarify, cutting latency from up to 1.5s to near-instant. Got all the correctness details right (atomic subscribe + snapshot, notify-inside-lock, head-of-queue payload, trailing event re-emission).

@happy5318 — Custom provider model dedup (PR #1947)
Fixed the same model from different named custom providers being silently deduplicated in the picker, with Opus catching a race in the original tests that needed augmentation.

@NocGeek — Streaming scroll + manual cron output persistence (7 PRs)
Streaming scroll viewport stability when tool/queue cards insert (#1360), manual cron-run output and metadata persistence (#1372, split from held #1352).

@DavidSchuchert — German translation (PR #190)
Complete German locale (de) covering all UI strings, settings labels, commands, and system messages — and stress-tested the i18n system, exposing several elements that weren't yet translatable and getting them fixed as part of the same PR.

@Bobby9228 — Mobile Profiles button (PR #265)
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones.

@kevin-ho — OLED theme (PR #168)
The 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk.

@andrewy-wizard — Chinese localization (PR #177)
Initial Simplified Chinese (zh) locale. One of the first non-English locales.

@DelightRun — session_search fix for WebUI sessions (PR #356)
Tracked down the missing SessionDB injection in the streaming path that was silently breaking the tool for every WebUI session.

@lawrencel1ng — Bandit security fixes (PR #354)
Systematic bandit-scan fixes: URL scheme validation before urlopen, MD5 usedforsecurity=False, and 40+ bare except: pass blocks replaced with proper logging.

@shaoxianbilly — Unicode filename downloads (PR #378)
Proper Content-Disposition with RFC 5987 filename*=UTF-8''... encoding so non-ASCII filenames download without crashing.

@lx3133584 — CSRF fix for reverse proxy (PR #360)
A real-world blocker for anyone hosting behind Nginx Proxy Manager or similar on a port other than 80/443.

@betamod — Security audit (PR #171)
A comprehensive CSRF / SSRF / XSS / env-race-condition audit that shipped in v0.39.0.

@TaraTheStar — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
Configurable assistant display name, thinking/reasoning block display, and a login page refactor.


Repo

text
[email protected]:nesquena/hermes-webui.git

Overview (`docs/rfcs/README.md`)

RFCs

This directory holds design documents for hermes-webui features that are
worth thinking through in writing before (or alongside) implementation —
typically when the change touches durability, recovery, schema, or cross-
cutting infrastructure.

Conventions

  • One file per RFC. Filename is the topic (kebab-case), not a number.

  • Top of every RFC carries a small header:

    text
    - **Status:** Proposed | Accepted | Implemented | Withdrawn
    - **Author:** @github-handle
    - **Created:** YYYY-MM-DD
  • Sections usually include: Problem, Goals, Non-goals, Proposal, Open
    questions, Rollout plan. Skip what doesn't apply.

  • An RFC is a starting point for review. Comments and revisions land via PR
    edits, not separate discussion threads.

  • An RFC documents a design direction. It is not an invitation to file
    implementation PRs against fragments of it. Before opening any PR that
    implements an accepted RFC, confirm with a maintainer in the tracking
    issue that the implementation slice is wanted and that no other
    contributor is already building it. Speculative implementations of RFC
    fragments without a confirmed integration site will be held.

When to file an RFC

  • The change is large enough that you want consensus before writing code.
  • The change touches data-at-rest formats or recovery semantics.
  • The change introduces a new architectural primitive (journal, queue,
    scheduler, cache layer) that other features will build on.
  • A reviewer asks for one during code review.

When in doubt, just ship the code — small features don't need RFCs.
First-time contributor RFCs should be discussed in an issue before opening a PR.

Current RFCs

  • hermes-run-adapter-contract.md — #1925
    event/control contract, runtime-state ownership matrix, acceptance catalog,
    and reversible migration gates for moving WebUI execution behind an explicit
    adapter boundary.
  • webui-run-state-consistency-contract.md
    — #2361 consistency rules for keeping transcript, model context, live streams,
    replay, compression, and session metadata coherent during active and recovered
    WebUI runs, including the per-layer authority matrix (authority, persistence
    lifetime, allowed divergence, replay/recovery rules) and the review checklist.
  • live-to-final-assistant-replies.md
    — #3400 accepted product model for long-running assistant replies, live
    process prose, tool activity, recovery, terminal outcomes, display
    projections, and the final-answer boundary.
  • transparent-stream-activity-mode.md
    — #3820 implemented opt-in display mode for power users who need a
    transparent, chronological Thinking / progress / tool-call stream alongside
    the default Compact Worklog and opt-in Final answer only projections.
  • stable-assistant-turn-anchors.md — #3926
    implemented frontend presentation/reconciliation model for anchoring live
    assistant activity, settled final answers, replay, and all activity display
    modes to one assistant turn; remaining hardening is tracked under #3400.
  • canonical-session-resolution.md — #2361
    focused contract for resolving URL, query parameter, localStorage, sidebar,
    and compression-lineage session IDs to one canonical visible chat target.
  • turn-journal.md — Crash-safe WebUI turn journal for
    recovering interrupted chat submissions.
  • webui-pending-intent-controls.md —
    #3058 control-surface companion to #3400 for Queue, Steer, Stop-and-send,
    Interrupt, and leftover-steer inputs submitted while a long-running agent
    session is active.
  • session-sse-contract-v1.md — #4812 Proposed
    contract vocabulary, replay identity, event taxonomy, cursor/resume semantics,
    and implementation gates for GET /api/sessions/{session_id}/events.
  • exact-persisted-message-replay-guard.md
    — proposed persistence-boundary invariant that removes only byte-equivalent
    replay rows carrying stable event identity before a session save.

Overview (`docs/pr-media/6469-sidebar-handoff/README.md`)

Failure and scope

The independent /api/sessions response can report idle before the chat SSE's
terminal frame reaches the page. Three sidebar paths were retiring chat state:
idle reconciliation, INFLIGHT purging, and optimistic-row retirement. The idle
path clears the Worklog and active stream ID; the subsequent done is rejected
as stale, so no settled Anchor scene is persisted.

A real-HTTP barrier reproduces this on both published PR head
75c8ff8d2a991f22389e3e4263ee03fb8d8f67a0 and frozen upstream
b09fded7b8c98b549a49aaaece3721015337d307. Applying only the sessions.js
product fix to the frozen upstream also passes the identical oracle: this is a
shared lifecycle defect, not evidence that artifact replay introduced it. The
historical hosted run lacks this instrumentation, so its exact interleaving
cannot be proved retrospectively; its empty-surface/no-persistence signature
matches the controlled reproduction.

Reproduction

sh
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=settle-worklog-frame-proof \
  .venv/bin/python tests/browser_conversation_lifecycle.py
./scripts/test.sh tests/test_sidebar_terminal_handoff.py -q

The existing frame proof now uses a test-only server wrapper to hold the actual
done HTTP write. The real worker finishes; the real browser receives an idle
sidebar response through renderSessionList() while its native EventSource
remains open. The test verifies pane, INFLIGHT and registry ownership, then
releases done and retains all existing painted-frame, final-answer, semantic
activity and hard-reload assertions. The fixture always releases/cleans up and
has a bounded wait. Normal production server startup is unchanged.

No renderer, terminal payload or persistence response is fabricated. Runtime
events come from the existing deterministic localhost Gateway fixture, not a
live model/provider. All state and files are disposable. These are browser
viewport checks, not physical-device acceptance.

Before / after

Each screenshot is taken after the idle sidebar response, before the terminal
HTTP frame is released. The exact published source fails at both widths. The
patched source preserves three live activity rows and then passes completion
and hard reload. results.json records the predicate results without machine
paths or credentials.

Viewport Before After
1440 x 900 Empty surface Owned handoff retained
390 x 844 Empty surface Owned handoff retained

The fix only defers these sidebar cleanups for the current pane's exact OPEN
chat transport. Missing, CONNECTING, CLOSED and mismatched sources still take
the existing stale recovery path.

Missing terminal frame: bounded recovery

The OPEN-only guard at 85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e could wait
forever when the terminal frame never arrived. The follow-up keeps the live
handoff for 1.5 seconds, then probes the exact stream through
/api/chat/stream/status with an eight-second timeout and no retries.
Repeated sidebar hints cannot restart the deadline or create concurrent
requests. active:true keeps the OPEN owner intact even when the persisted
session fields already look idle; only explicit runtime inactivity permits the
canonical session restore. A failed or malformed runtime probe uses the
existing interrupted-stream path rather than claiming successful settlement.
Normal terminal delivery, transport replacement/close, navigation, and a newer
optimistic turn invalidate the old request. Already-running stream-end recovery
remains the sole recovery owner.

sh
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=sidebar-idle-missing-terminal \
  .venv/bin/python tests/browser_conversation_lifecycle.py
./scripts/test.sh tests/test_sidebar_idle_stream_recovery.py -q

The new browser row keeps the real terminal HTTP barrier closed until cleanup;
the client must recover without receiving done or stream_end. Published
85f1c2d34b fails the 11-second completion bound with S.busy still true.
The patched implementation restores the final answer and passes the unchanged
painted-frame, semantic activity, persistence and hard-reload checks before the
barrier is released. The cloud workflow runs both delayed- and missing-terminal
rows. No live provider or production state is used.

A production Gateway regression also blocks post-turn goal evaluation after
the success transcript has already cleared active_stream_id and pending
fields. During that interval the worker and exact entry in STREAMS remain
live and no done event has been emitted, proving why persisted session flags
cannot authorize sidebar settlement on their own.


Overview (`docs/pr-media/7245/README.md`)

PR #7245 — retired message-action failure proof

Captured on 2026-09-20 in Chrome 153.0.8010.52 with fresh, disposable browser
contexts, at 1440 × 900 and 390 × 844. No real sessions, model calls, credentials,
production state, or existing browser profile were used.

What the images prove

These are explicitly synthetic transcript fixtures, not screenshots of a live
user conversation. They compose the current production message-action rendering
helpers from static/ui.js, the actual icons/styles, and the complete extension
runtime. The fixture's status line displays the message passed to Core's error
callback; it is not a screenshot of the full application's toast styling.

The before/after variable is the runtime: before uses published PR head
b3c2e39f219b9778200fdeebd1a777764c112726; after uses the repaired runtime on the
integration containing upstream 10003f46d48415a7f5691b2018ad76bfe5486863.

The test clicks an action, unregisters it, registers and starts a replacement
with the same ID/target, then rejects the original native Promise. Before,
the old invocation reports one error even while the replacement is correctly
pending. After, it reports none; the replacement remains pending until its own
completion. Neither case produces an uncaught browser exception.

Viewport Before After
1440 × 900
390 × 844

The same browser fixture also exercised pressed-state invalidation, duplicate
click suppression, concurrent targets, attachment-only empty text, frozen click
context, connected-opener focus, HTML-cache reconstruction, hidden-row exclusion,
stale-index rejection, and uninstall cleanup. Narrow action targets measured
40 × 40 px. This does not prove every full-app virtualization or network-recovery
schedule; those retain the separate repository tests and hosted lifecycle gates.

Reproducible regression tests

sh
./scripts/test.sh tests/test_extension_message_actions.py -k retired_message_action -q

All four variants failed at the observable error callback before the product fix
and passed afterward: unregister, same-ID replacement, uninstall, and a thenable
that reports failure after already reporting success. Existing tests continue to
verify that errors from a current invocation are surfaced.

A separate agent-free smoke check booted the real server.py with isolated HOME,
state and workspace on loopback, then loaded /, /#settings, and /#sessions
at both viewports: zero console errors and zero uncaught exceptions. The fixture
images above are not presented as evidence of that separate full-app check.


Overview (`docs/pr-media/reasoning-hydration/README.md`)

Stored reasoning identity: browser evidence

These are synthetic persisted-session fixtures, not user conversations. They run
through Session.save(), the real WebUI HTTP session loader and the unmodified
browser renderer. No Agent/provider invocation is made. Public static assets load
as in the existing browser gates; this is not a network sandbox certification.

Frozen baseline: ff26335b87610f70d74ff008608a321fb1281bd1.
The fixture contains two same-text events with IDs A/B and one exact redelivery
of A. Before the API fix, both initial load and hard reload show only A. After the
fix they show A/B exactly once. Tests assert the DOM row identities, not merely
that two labels are visible. The Thinking cards are collapsed in these captures;
the machine-readable snapshots also check their identical retained text.

State Desktop (1280px) Narrow (390px)
Before
After

Reproduce from the candidate checkout:

sh
.venv/bin/python tests/browser_reasoning_identity_hydration.py --artifact-dir /tmp/reasoning-proof

The same driver on the baseline fails all four load/reload × desktop/narrow
identity assertions. It uses disposable Hermes/state/workspace directories and
stops its own server/browser; it does not modify production sessions. Test setup
initially lacked the seed directory and a trial's blanket external-resource block
caused console failures. Neither was counted as product RED evidence. The final
baseline/candidate comparison uses the same corrected driver.

October 1 filtered-reasoning regression

Baseline: reviewed head 5ca72febbab3 plus normal upstream merge
dd5451903686 (product fix absent). A persisted running Thinking row carries
its exact stream identity. Transcript reasoning either repeats visible progress
or matches the final answer; neither should consume a reconciliation slot.

Both variants fail the intended DOM identity assertion on baseline: the two saved
Thinking events are absent on load and hard reload at 1280px and 390px. After the
slot filter, both variants preserve A/B exactly once in all eight combinations.
The four images below show the visible-prose variant after hard reload; the
final-answer variant has the same missing/preserved event outcome.

State Desktop (1280px) Narrow (390px)
Before
After
sh
.venv/bin/python tests/browser_reasoning_identity_hydration.py --filtered-reasoning visible-prose --artifact-dir /tmp/reasoning-filter-prose
.venv/bin/python tests/browser_reasoning_identity_hydration.py --filtered-reasoning final-answer --artifact-dir /tmp/reasoning-filter-final

These remain synthetic, isolated Chromium checks; no physical-device or live
provider behavior is claimed. An initial trial lacked per-row stream ownership
and passed on baseline, so it is not negative proof. A second trial waited for
an absent worklog and timed out; the final driver captures missing rows and fails
on their identities instead of counting a timeout as evidence.

October 1 second review: missing reasoning metadata

No-tool thinking content with no reasoning metadata must not allocate a
settlement slot that discards a distinct saved running event. The synthetic
persisted fixture contains Transcript thought in content parts and saved
Distinct saved thought with event ID saved-event and row ID saved-row.

The baseline is reviewed head b2fadb981b90988138d1a34bf55b2bf415ac4471
plus upstream de512304b306211e71da103b5c218b17ef8d4ddc, merge commit
7cf6fc55b7a5244cf25960cd24fd7ab2bd153ee0. On that baseline, actual server
load and hard reload lose the saved ID and show Transcript thought at both
1280 and 390 px. The repaired product commit
6a3c432fde1a97d5261696bdac04cdbb210d8cf1 preserves the saved row/event IDs,
Distinct saved thought, completed status, and exactly one final answer.
Both missing and empty metadata cases pass all eight width/load combinations.
Thinking details were opened with real header clicks before capture.

Width Before After
Desktop 1280 Before After
Narrow/mobile 390 Before After
sh
python tests/browser_reasoning_identity_hydration.py --no-tool-reasoning missing-metadata --artifact-dir /tmp/reasoning-no-metadata
python tests/browser_reasoning_identity_hydration.py --no-tool-reasoning empty-metadata --artifact-dir /tmp/reasoning-empty-metadata

The driver launches isolated temporary state/workspace and an Agent-free stub;
no real provider or personal session is used. Existing visible-prose/final-answer
filtering and equal-text/different-ID browser cases also passed.

October 1 third review: transcript Markdown owns the prose body

Baseline: reviewed head b0bf02710664 plus pinned upstream
e33da25c6cfde3ea337e1b6d7701161d02e995cf, normal merge
b6198a23fa9493f9a24d5f45c549fbd722196e52. The saved running prose row
carries saved-prose-event / saved-prose-row, but its live text has collapsed
whitespace. The settled transcript contains exact Markdown and a read_file
tool call before the final answer.

Before the repair, hydration inherits the saved row's flattened body as well as
its identity: the fenced block has no native pre code element, and the two
lists collapse into one item. After the repair, hydration retains the exact
transcript text and payload, source and grouping metadata, while inheriting
only durable identity fields. The browser keeps a native fenced block with
print(42), an unordered list with a / b, an ordered list with c, one tool
row, and one final answer through both initial load and hard reload. The images
below capture hard reload at 1280 × 900 and 390 × 900.

Fixture / state Desktop Narrow/mobile
Fenced code before Before Before
Fenced code after After After
Lists before Before Before
Lists after After After
sh
.venv/bin/python tests/browser_reasoning_identity_hydration.py --transcript-markdown fenced-code --artifact-dir /tmp/prose-fenced
.venv/bin/python tests/browser_reasoning_identity_hydration.py --transcript-markdown lists --artifact-dir /tmp/prose-lists
.venv/bin/python tests/browser_reasoning_identity_hydration.py --transcript-markdown indented-code --artifact-dir /tmp/prose-indented

The exact indented-code review fixture is covered too: hydration must retain
print(42)\n print(43) byte-for-byte in scene text and payload. Current
renderMd already renders this fixture as a paragraph when given the exact
transcript, rather than a native indented code block. The browser's indented
case checks source ownership and parity with that existing renderer; this
repair does not claim to add native indented-code syntax support. Existing
Thinking text/identity behavior and metadata-only no-tool reasoning boundaries
remain covered by the same driver and focused regressions.

These are synthetic persisted sessions, real HTTP loads and Chromium DOM
checks, with isolated temporary Hermes/state/workspace and no provider request
or personal data. Machine-readable snapshots capture both load and reload.


CONTRACTS (`docs/CONTRACTS.md`)

Project Contracts

This document is a contributor-facing index for existing Hermes WebUI contracts,
RFCs, design constraints, and review expectations. It does not replace the
source documents and it does not mark proposals as implemented. Follow each
linked document's status and scope.

Use this file when starting a change so the relevant public contract is visible
before code is edited. This index focuses on documentation routing and
contributor guidance; it does not change runtime behavior or CI gates.

Start here

  • AGENTS.md: repository entry point for AI assistants,
    public-safety rules, and the short redline checklist.
  • CONTRIBUTING.md: contribution style, verification,
    PR description expectations, UI evidence, and project-specific constraints.
  • README.md: product overview, quick start, architecture map,
    feature inventory, and docs index.
  • CHANGELOG.md: release history maintained by the release
    workflow. Read it for context, but do not edit it in ordinary contributor PRs;
    put release-note-ready wording in the PR body instead.

Runtime, durability, and state contracts

  • docs/remote-workspaces.md:
    architecture contract for remote terminal workspaces (SSH/Docker), target-side
    POSIX path preservation against macOS synthetic firmlink expansion, and
    per-profile isolation boundaries.
  • docs/rfcs/webui-run-state-consistency-contract.md:
    proposed consistency rules for current WebUI streaming, recovery, replay,
    model-context reconstruction, compression, UI scene/cache, and sidebar metadata
    repairs. Start here for narrow fixes that keep the existing WebUI execution
    path.
  • docs/rfcs/live-to-final-assistant-replies.md:
    accepted product model for long-running assistant replies, live process text,
    tool activity, recovery, terminal outcomes, and final-answer boundaries. Start
    here for UI/UX changes to running-session assistant reply rendering.
  • docs/rfcs/stable-assistant-turn-anchors.md:
    implemented presentation/reconciliation model that attaches live, settled,
    replayed, and recovered activity to one assistant-turn owner and projects one
    activity_scene_v1 into Compact Worklog, Transparent Stream, or Final answer
    only. Remaining hardening stays tracked under #3400.
  • docs/architecture/stable-assistant-turn-anchor-phase0.md:
    cumulative implementation inventory for the Stable Assistant Turn Anchors
    work under #3926. Use it to distinguish shipped wiring from historical slice
    boundaries before changing live SSE, replay, settlement, INFLIGHT, or
    renderMessages() paths.
  • docs/architecture/transcript-auto-follow-scroll.md:
    implemented contract for transcript auto-follow: the pinned/unpinned state
    model, the input-tail capture and reader-resume re-pinning rules (including
    one-consumption-per-input and reader input outranking queued restores), and
    the overscroll-behavior-y: none rationale for .messages. Start here
    before changing the scroll listener, the settle writer, or the queued
    live-render restore paths.
  • docs/rfcs/canonical-session-resolution.md:
    proposed contract for resolving URL routes, query parameters, localStorage,
    sidebar rows, and compression-lineage IDs to one canonical visible session
    target. Start here for session routing, boot restore, stale parent, or
    compression-tip selection changes.
  • docs/rfcs/hermes-run-adapter-contract.md:
    proposed event/control contract, runtime-state ownership matrix,
    acceptance-test catalog, and reversible migration gates for moving WebUI
    execution behind an adapter boundary. Use this for adapter-seam, control-plane,
    runner, sidecar, or execution-ownership work; do not treat it as authorization
    to implement those slices.
  • docs/architecture/agent-api-contract.md:
    current audit of WebUI dependencies on the hermes-agent source checkout and
    the replacement API/client surfaces needed before source mounts can be removed.
    Start here for issue #2491 and Docker/source-boundary migration slices.
  • docs/rfcs/turn-journal.md: proposed crash-safe
    write-ahead journal for browser-originated chat turns.
  • docs/rfcs/webui-pending-intent-controls.md:
    proposed control-surface companion to the long-running-session reply model for
    Queue, Steer, Stop-and-send, Interrupt, and leftover-steer inputs submitted
    while an agent run is active. Start here for busy-composer behavior, pending
    queued messages, interrupt replacement, steer visibility, or leftover-steer
    recovery changes.
  • docs/rfcs/README.md: RFC conventions and current RFC index.
  • docs/rfcs/session-sse-contract-v1.md:
    proposed contract vocabulary, cursor/resume semantics, replay identity, snapshot
    fallback, event taxonomy, and implementation gates for the per-session SSE
    stream GET /api/sessions/{session_id}/events (#4812). Distinct from the
    existing global session-list stream GET /api/sessions/events. Start here for
    any work that touches per-session SSE, Last-Event-ID replay, or session
    lifecycle event delivery. The Phase 1 server route and journal relay for
    GET /api/sessions/{session_id}/events are implemented; broader client,
    platform, and semantic-taxonomy claims in the RFC remain behind the recorded
    proof gates. Prefer the RFC's Authoritative emitted events table (live
    /api/chat/stream wire names) over the aspirational semantic taxonomy when
    writing clients against current source.
  • docs/architecture/models-cache-invalidation.md:
    current contract for the /api/models catalog cache identity: the
    config.yaml, auth.json, and catalog source-fingerprint axes, the
    one-directional volatile-key deny-lists (auth rotation and Codex's
    models_cache.json refresh timestamps), the stat-identity fallbacks, and the
    schema/version stamps. Start here before changing model-catalog caching, the
    /api/models cache keys, or the Codex catalog dependency (#2443, #7540).
  • docs/architecture/send-path-model-provider.md:
    current contract for which provider the browser routes an outgoing chat turn
    to: the _modelProviderForSend() precedence order (explicit @provider:model
    tag → dropdown provider with session-scoped pick evidence → the loaded
    session's own provider → persisted state → null), why a bare dropdown match
    is not intent after a restore, and the lifecycle of the picker's explicit-pick
    marker. Start here before changing provider precedence for outgoing turns,
    the picker's pick evidence, or restored-session provider resolution
    (#7860, #7865).
  • docs/architecture/live-models-allowlist.md:
    current contract for how /api/models/live filters a custom provider's
    upstream catalog: the four signals in evaluation order (discovered catalog
    defers to the live probe, explicit plural models: allowlist filters,
    singular model: never gates, no allowlist shows the full catalog), the
    serialized-list shapes hermes config set persists, the deliberate
    empty-allowlist-is-not-configured rule, and the probe-failure fallback.
    Start here before changing custom-provider model filtering or
    discovery-vs-allowlist semantics (#7165, #7404).
  • docs/architecture/profile-home-resolve-cache.md:
    current contract for the call-scoped memoization around
    _resolve_profile_home_param() in api/workspace.py: what it caches, the
    one-hot-loop scope (_load_cli_sessions_uncached) versus every other call
    site getting fresh resolution, and the freshness guarantee that a symlink
    retarget or transient resolve fallback is always observed on the next call.
    Start here before widening this cache's scope or adding a similar
    call-scoped cache elsewhere (#7636).
  • docs/architecture/session-channel-lifecycle.md:
    current contract for the per-session SSE channel and the per-turn stream lifecycle: the state
    layers and their authoritative writers, the writer-side subscriber-liveness signal plus the
    channel collection rules (GRACE, IDLE_TTL, queue-stall and writer-staleness), the single
    orphan predicate shared by chat/start admission, busy reporting and the reaper, the
    launch-phase claim that covers registration-to-admission, and the lock discipline
    (STREAMS_LOCK → ACTIVE_RUNS_LOCK). Start here before changing subscriber liveness, channel
    collection, stream registration, or orphan reclaim (#7302).

When a change touches streaming, recovery, replay, compression, context
reconstruction, cancellation, approval/clarify, session metadata, or run state,
read the relevant RFC before editing. In the PR description, name the state layer
or event/control surface affected and include a regression test or manual
verification for the relevant invariant.

Proposed RFCs are review guardrails, not implementation authorization. Do not
implement RFC fragments unless the task or tracking issue explicitly asks for
that slice.

UI, UX, and theme contracts

  • DESIGN.md: design tokens and the current calm-console
    direction: conversation first, quiet metadata, restrained accents, and
    progressive disclosure for debugging detail.
  • docs/UIUX-GUIDE.md: contributor-facing synthesis of the
    repository's UI/UX principles, sourced from existing project docs and code
    comments.
  • docs/ui-ux/index.html: message-area inventory wired to
    the real app stylesheet.
  • docs/ui-ux/two-stage-proposal.html:
    existing two-stage chat UX proposal for issue #536.
  • THEMES.md: theme and skin guidance; the core palette
    variable contract lives in static/style.css.

Current appearance has a theme axis (light, dark, system) and a separate
skin axis (default, ares, mono, slate, poseidon, sisyphus,
charizard, sienna, catppuccin, nous, geist-contrast) in
static/boot.js and static/style.css. Do not follow stale data-theme-only theme guidance unless
the current code and tests prove that model still applies.

For UI or UX work, include before/after evidence, verify relevant responsive
states, and prefer stable class/data hooks over one-off visual behavior.

Choosing the relevant contract

Before editing, identify which contract family the task exercises. This is a
routing check, not a request to read every document in the repository. Read the
documents that match the touched subsystem.

Use this lightweight note in an issue comment, draft PR, task note, or AI-agent
handoff when it helps clarify scope:

markdown
## Contract Routing

Task type:
Touched areas:
Relevant public docs:
- `AGENTS.md`
- `CONTRIBUTING.md`
- `docs/CONTRACTS.md`
- <subsystem-specific documents>
Scope boundaries:
Evidence needed before claiming done:

For small, obvious fixes, keep this short. The goal is to avoid routing mistakes,
not to create process overhead.

Contract changes

Changing contract documents, RFC guidance, or contract tests changes review
expectations for future contributors. A PR that intentionally changes an
existing contract should include a Contract Change section in its PR body with:

  • the previous contract,
  • the new contract,
  • the affected docs and tests,
  • the compatibility or migration reason.

Contract tests and corresponding docs must move together. Tests that encode
product semantics must not silently redefine the contract by asserting the
opposite behavior without updating the public docs and naming the change in the
PR body.

The static tests for this guidance are advisory coverage. They pin contributor
wording so the rule stays visible. This advisory coverage is not an automated
policy gate; static coverage is not an automated policy gate and does not enforce
PR-body content on GitHub. A future release-time or CI check could
surface contract-affecting diffs whose PR body lacks Contract Routing, but this
document only defines the review expectation.

Release batches should list included contract-affecting PRs explicitly so
reviewers can distinguish ordinary green-CI fixes from changes that update the
project's product or runtime guardrails.

PR preparation checklist

Before opening or updating a PR, verify CONTRIBUTING.md against the actual PR
body. This checklist applies even when code and tests are already done.

Required checks:

  • The PR solves one logical problem.
  • The PR body contains all required sections from CONTRIBUTING.md:
    Thinking Path, What Changed, Why It Matters, Verification,
    Risks / Follow-ups, and Model Used.
  • Model Used discloses provider/model and notable agent/tool use, or says
    None -- human-authored.
  • UI/UX changes include before/after evidence and responsive-state coverage.
  • Runtime/streaming changes name the state layer or invariant being changed and
    list the regression or manual invariant check.
  • Contract-affecting PRs include Contract Routing; intentional contract
    changes also include Contract Change.
  • Onboarding/setup validation used isolated HERMES_HOME and
    HERMES_WEBUI_STATE_DIR, unless the human operator explicitly requested real
    state.
  • Docs updates are included or explicitly not needed, and release-note-worthy
    changes are described in the PR body rather than by editing CHANGELOG.md.
  • After the GitHub write, read the PR back and verify the headings rendered as
    intended.

Green CI plus a focused diff is not sufficient if the PR description or evidence
does not match the touched subsystem.

Setup, onboarding, and operational references

Quick redline checklist

Before opening a change for review, confirm:

  • The change solves one logical problem; unrelated refactors are split out.
  • AGENTS.md, this index, and any linked contract for the touched subsystem were
    read before editing.
  • Behavior, setup, architecture, testing, or workflow changes update the relevant
    docs; release-note-ready changes include PR-body release-note wording while
    CHANGELOG.md is left to release commits.
  • UI/UX changes include before/after evidence and cover relevant desktop,
    narrow, and mobile states.
  • Runtime, streaming, recovery, replay, compression, or sidebar changes state
    which layer they mutate and include a regression for the invariant.
  • New dependencies, build tools, frameworks, or long-lived processes are avoided
    unless the benefit and rollback story are explicit.
  • Onboarding/setup validation uses isolated HERMES_HOME and
    HERMES_WEBUI_STATE_DIR unless the human operator explicitly asks to use real
    state.
  • Secrets, private paths, local-only workflows, and personal notes stay out of
    tracked docs and examples.

Future evolution

This index is not intended to make the first contract set final. Future PRs may
add, revise, split, or retire contracts when real issues, implementation changes,
RFC decisions, contributor feedback, or review experience show that guidance is
incomplete or stale.

Potential follow-up areas include session import/export, cron, extensions,
security boundaries, Docker/runtime isolation, and lightweight checks that keep
key contract links from drifting.


EXTENSIONS (`docs/EXTENSIONS.md`)

WebUI Extensions

Hermes WebUI supports a small, opt-in extension surface for self-hosted installs.
It lets an administrator serve local static assets and inject same-origin CSS or
JavaScript into the app shell without editing the WebUI source tree.

Trust model — read this first. Extensions execute with full WebUI session
authority. An extension JS file can call any API the logged-in user can call,
including reading conversation history, sending messages, modifying settings,
and triggering tool actions. Only enable extensions you wrote yourself or
from sources you trust as much as the WebUI source itself.
If your WebUI is
shared with users you do not fully trust, do not enable extensions.
If you set HERMES_WEBUI_EXTENSION_DIR yourself, do not point it at a
user-writable directory on a shared host.

This is intentionally not a plugin marketplace or dependency system. It is a
safe escape hatch for local dashboards, internal tooling, and workflow-specific
panels that should not live in core Hermes WebUI.

The vetted extension library. The curated, one-click-installable extensions
that appear in the gallery live in a separate public repo:
hermes-webui/hermes-webui-extensions.
"In the registry == vetted." That repo holds the entries, the authoring
conventions (docs/extension-entry.md),
the JSON schema, and the CI safety gates. This document covers the WebUI-side
infrastructure (loader, manifest contract, capabilities, install client);
see the library repo to browse existing extensions or contribute a new one.

What extensions can do

Extensions can:

  • serve files from one configured local directory at /extensions/...
  • inject configured same-origin stylesheets into <head>
  • inject configured same-origin scripts before ``
  • read a local JSON manifest that lists bundled scripts/styles to inject
  • call the normal WebUI APIs available to the browser session
  • call trusted local loopback sidecars directly from extension JavaScript when
    the browser Content Security Policy allows that origin

Extensions cannot, by themselves:

  • bypass WebUI authentication
  • serve files outside the configured extension directory
  • load third-party scripts/styles through the built-in injection config
  • register new WebUI backend routes or proxy arbitrary sidecar/backend traffic
    outside the fixed consented extension sidecar path described below
  • change Hermes Agent permissions, models, memory, or tools unless they call
    existing authenticated APIs that already allow those changes

Configuration

One-click install (no configuration required)

For a single-user self-hosted instance you do not need to configure anything.
Open Settings → Extensions, pick an extension from the gallery, and click
Install — it just works. The first install creates a WebUI-managed
extension directory under your state dir (STATE_DIR/extensions, e.g.
~/.hermes/webui/extensions/) and installs into it; gallery-installed
extensions load automatically on the next app-shell render with no environment
variables and no restart of your shell.

The managed directory lives alongside your sessions and settings in the
WebUI-owned state dir. That is a different trust domain from "a world-writable
directory on a shared box": only the WebUI process (and whoever can already
write your ~/.hermes state) can place code there. The trust model below still
applies — installed extension code runs with full session authority — so only
install extensions from the vetted gallery or sources you trust as much as the
WebUI source itself.

Some gallery entries need more than WebUI assets. If an extension declares
post-install guidance or lifecycle requirements such as a loopback sidecar or a
native host, Settings -> Extensions shows a Next step note on the card after
install. For example, Desktop Companion can install the WebUI bridge from the
gallery, but the desktop pet is only visible after the local Desktop Companion
app is started.

Manual / advanced configuration (optional)

HERMES_WEBUI_EXTENSION_DIR is optional and overrides the managed default.
Set it when you want extensions to live in a specific directory you control
(e.g. a checked-out bundle, or a path mounted into a container). When set it
must point to an existing directory before any script or stylesheet URLs are
injected; WebUI never auto-creates an admin-specified path:

bash
export HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static
export HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/app.js
export HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/app.css
./start.sh

Multiple URLs may be comma-separated:

bash
export HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/runtime.js,/extensions/app.js
export HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/base.css,/extensions/theme.css

For bundled or multi-extension installs, you may list assets in a manifest file
inside HERMES_WEBUI_EXTENSION_DIR instead of maintaining long comma-separated
environment variables:

bash
cat > ~/.hermes/webui-extension-bundle/extensions.json <<'JSON'
{
  "extensions": [
    {
      "id": "templates",
      "scripts": ["templates/templates.js"],
      "stylesheets": ["templates/templates.css"]
    },
    {
      "id": "sidebar-tools",
      "scripts": ["sidebar-tools/sidebar-tools.js"],
      "stylesheets": ["sidebar-tools/sidebar-tools.css"]
    }
  ]
}
JSON

HERMES_WEBUI_EXTENSION_DIR=~/.hermes/webui-extension-bundle \
HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \
./start.sh

Manifest entries use the same URL safety rules as the environment variables.
Bare relative entries such as templates/templates.js resolve to
/extensions/templates/templates.js; absolute same-origin entries such as
/extensions/shared.js or /static/theme.css are also accepted. A manifest may
be an object with top-level scripts / stylesheets, an object with an
extensions array, or a top-level array of extension objects. Disabled entries
may be kept in the manifest with the JSON boolean "enabled": false. Explicit
HERMES_WEBUI_EXTENSION_SCRIPT_URLS and
HERMES_WEBUI_EXTENSION_STYLESHEET_URLS still work and are appended after
manifest assets, with duplicates ignored.

When an extension is installed from Settings -> Extensions, WebUI records the
installed package and loads that package's manifest.json automatically on the
next app-shell render. In this gallery-installed mode, a manifest located at
HERMES_WEBUI_EXTENSION_DIR/<extension-id>/manifest.json resolves bare relative
assets relative to that package directory. For example,
"scripts": ["assets/companion-adapter.js"] in
desktop-companion/manifest.json injects
/extensions/desktop-companion/assets/companion-adapter.js.

Manual manifests configured with HERMES_WEBUI_EXTENSION_MANIFEST follow the
same rule: relative assets resolve from the manifest file's directory. A root
manifest such as extensions.json keeps the existing
/extensions/<asset-path> behavior, while a subdirectory manifest such as
desktop-companion/manifest.json resolves relative assets under
/extensions/desktop-companion/.

Extension entries may also declare a loopback sidecar for diagnostics and the
opt-in proxy:

json
{
  "extensions": [
    {
      "id": "desktop-companion",
      "name": "Desktop Companion",
      "scripts": ["companion-adapter.js"],
      "stylesheets": ["companion-adapter.css"],
      "sidecar": {
        "type": "loopback",
        "origin": "http://127.0.0.1:17787",
        "health_path": "/health",
        "proxy_auth": "token-v1"
      }
    }
  ]
}

Loopback sidecars do not change asset injection behavior. They are reported
by diagnostics so an operator can see that a local companion service was
declared and optionally check its health from the browser. If the operator later
approves the proxy in Settings → Extensions, WebUI may proxy requests only
through the fixed per-extension sidecar path for that extension.

Sidecar proxy authentication (`proxy_auth`)

The loopback port a sidecar binds is reachable by any local process, and the
proxy strips every inbound credential (cookies, Authorization, CSRF, x-hermes-*)
before forwarding — so a sidecar cannot, on its own, tell a proxied request from a
direct one. The proxy_auth field closes that gap:

  • token-v1 (recommended for any sidecar that mutates state) — WebUI mints a
    per-extension secret at STATE_DIR/sidecar-auth/.token (mode 0600) and
    injects it as the X-Hermes-Sidecar-Token header on every proxied request. The
    sidecar resolves the token file in this order — HERMES_EXT_SIDECAR_TOKEN_FILE
    → $HERMES_WEBUI_STATE_DIR/sidecar-auth/.token →
    $HERMES_HOME/webui/sidecar-auth/.token → platform default
    (~/.hermes/webui/…, %LOCALAPPDATA%\hermes\webui\… on Windows) — and must
    validate the header on every route except /health, returning 401 on a
    missing/mismatched token
    and 503 when the token file is absent/unreadable.
    The canonical scaffold in the extensions repository (examples/, see
    docs/SIDECAR_CONTRACT.md) does all of this for you — do not hand-roll it.
  • absent (or the explicit literal "legacy") — legacy mode (no token;
    unchanged behavior). Only appropriate for read-only, non-sensitive sidecars.
  • Any other value fails closed (the sidecar declaration is rejected).

Auth-off posture: WebUI authentication is optional and off by default. Because
the consent endpoint and proxy route are unauthenticated in that mode, token-v1
fails closed regardless of whether the sidecar origin is loopback: consent and proxy
resolution return 403 until WebUI authentication is configured. Otherwise, any
caller that can reach WebUI could ask core to inject the token and use it as a
forwarding oracle. The extensions panel exposes the local_unprotected posture so
the operator is told to enable authentication before granting consent. The token
protects against other-UID and sandboxed local callers; it does not defend against
arbitrary same-UID code (which can read the token file, WebUI's own signing key, or
run the sidecar's tool directly).

Extension entries may declare browser-local settings when they also request
extension-owned storage:

json
{
  "id": "desktop-companion",
  "permissions": {
    "storage": {
      "owned": true
    }
  },
  "settings_schema": [
    {
      "key": "show_badge",
      "type": "boolean",
      "label": "Show badge",
      "default": true
    },
    {
      "key": "mode",
      "type": "enum",
      "label": "Mode",
      "options": [
        {"value": "compact", "label": "Compact"},
        {"value": "full", "label": "Full"}
      ],
      "default": "compact"
    }
  ]
}

Settings are a first-pass browser feature. WebUI sanitizes the manifest schema, injects the accepted schema before extension scripts, and leaves persistence to the browser. The backend does not store extension settings or expose a generic settings write route, and it does not treat these values as secrets.

The sanitizer accepts only boolean, string, number, integer, and enum
fields. It drops sensitive: true fields, unsupported types, malformed enum
options, duplicate keys after the first valid field, and defaults that do not
match the declared type. settings_schema is honored only when
permissions.storage.owned is exactly true.

Extension scripts can use the sanctioned browser accessors:

js
const settings = window.HermesExtensionSettings.settingsForExtension("desktop-companion");
const value = settings.get("show_badge");
settings.set("show_badge", false);

const storage = window.HermesExtensionSettings.storageForExtension("desktop-companion");
storage.set("lastPanel", "settings");

const sameSettings = window.hermesExt.settings.forExtension("desktop-companion");
const sameStorage = window.hermesExt.storage.forExtension("desktop-companion");

Settings persist only non-default overrides. Resetting settings removes those
overrides and returns schema defaults. Extension-owned storage uses a separate
browser-local namespace, and clearing storage removes that namespace without
changing settings.

Cooperative extension identity

An injected extension can claim a stable browser-page identity together with its
existing settings and storage accessors:

js
const ext = window.hermesExt.register("desktop-companion");
if (ext) {
  ext.settings.set("show_badge", false);
  ext.storage.set("lastPanel", "settings");
}

register(id) trims the ID and succeeds only for an ID in the effective-enabled,
Core-sanitized manifest inventory that was present at the initial page boot. It
returns null for empty, malformed, unknown, or untrusted IDs without creating
settings or storage state. Re-registering the same ID in one page returns the
same handle object; different IDs receive different handles. The handle exposes
only the canonical id, settings, storage, events, and messages fields. Settings and
storage are backed by the same factories used by the legacy accessors.

Manifest enable/disable changes still take effect after a WebUI reload. A handle
already created in the current page may remain cached across a status refresh;
this is expected and does not implement runtime unload. This identity is a
cooperative attribution convenience for extensions sharing the page—not a
sandbox, isolation mechanism, capability or permission system, or security
boundary. Extensions still execute with the full WebUI session authority
described above.

Custom Configure editors

Extensions with structured configuration that does not fit scalar
settings_schema fields can register one custom editor entry point on their
scoped E0 settings handle:

js
const ext = window.hermesExt?.register?.("dictionary-manager");
const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
  openDictionaryManager({ opener, restoreFocus });
});

registerConfigure(handler) is available only through a valid boot-trusted E0
handle. It does not require settings_schema or extension-owned storage. The
first handler registered for an extension wins; duplicates return null and do
not replace it. A successful registration returns an idempotent unregister
function.

Core shows one Configure button only for the extension's current
effective-enabled row under Settings → Extensions → Installed. Diagnostics
never shows the button. Late registration updates an already-mounted Installed
row. Disable or uninstall status removes the entry point immediately; uninstall
followed by a same-ID reinstall cannot revive the old page-local handler until a
full WebUI reload injects and registers the new extension script.

Core enters a pending state before invoking the handler and passes the activated
button as opener plus a one-shot restoreFocus() callback. The extension owns
its dialog, validation, persistence, and close lifecycle. It must either call
restoreFocus() when its UI closes or return a thenable whose settlement means
the Configure UI is closed. Both paths end pending and converge on Core's
one-shot focus restoration. A synchronous non-thenable handler remains disabled
while unsettled. If it never calls restoreFocus(), it remains disabled until reload.
Core does not guess dialog lifetime with a timeout.

Synchronous throws, thenable-access failures, and asynchronous rejections are
isolated, logged with the extension ID, and surfaced as a generic failure. Focus
returns to the connected opener when possible, otherwise to the current visible
Configure button or the Installed tab without forcing navigation. The hook does
not let an extension return DOM for Core to render, add a backend settings route,
or change the trusted same-origin extension model.

Message actions

Extensions can register a small, Core-rendered action on settled user and
assistant messages without querying or rewriting private transcript DOM:

js
const ext = window.hermesExt?.register?.("message-pins");
const unregister = ext?.messages?.registerAction?.({
  id: "pin",
  label: "Toggle pin",
  icon: "pin",
  roles: ["user", "assistant"],
  getPressed({ sessionId, messageIndex, role }) {
    return isPinned(sessionId, messageIndex, role);
  },
  onInvoke({ sessionId, messageIndex, role, text }) {
    togglePin({ sessionId, messageIndex, role, text });
  },
});

registerAction(descriptor) is available only through a valid boot-trusted E0
handle. It does not add a manifest permission or sandbox boundary. A descriptor
requires a stable ID ([A-Za-z][A-Za-z0-9._-]{0,63}), a plain-text label of at
most 120 characters, a Core-owned icon token (pin, bookmark, or star), and
an onInvoke function. roles is an optional non-empty subset of user and
assistant and defaults to both. Malformed descriptors, duplicate
extensionId:actionId identities, and registrations beyond the page-wide V1
limit of two return null. A successful registration returns an idempotent
unregister function, and unregistering releases its slot.

If extension-owned state changes somewhere other than the registered action
(for example, a pin is removed from an extension-owned header popover), call
ext.messages.invalidateActions(). Core then discards transcript HTML caches
and re-evaluates that extension's visible getPressed presentations. The call
returns true when the extension has an active message-action registration and
false otherwise.

Core places accepted actions after the built-in message controls in registration
order. It renders them only on visible, settled user/assistant message rows—not
live partial output, hidden worklog/anchor content, process-wakeup notices, tool
rows, or other surfaces. Core also reconciles late registration, rerender,
session-cache restore, pagination/virtualization, unregister, effective disable,
and uninstall. Uninstall quarantines the page-local identity until a full reload.

The optional getPressed callback is synchronous and receives a frozen
{ sessionId, messageIndex, role } object with no text. Only a strict true
sets aria-pressed="true"; a false value, exception, thenable, or thenable-access
failure is treated as unpressed without breaking transcript rendering.

On a user click, Core resolves the row again and passes onInvoke a frozen
{ sessionId, messageIndex, role, text } snapshot. messageIndex is the
absolute index in the current persisted session message list, including the
pagination offset. It is stable when older history is prepended, but it is not a
durable ID across edit, regenerate, or truncate operations. text is the
Core-normalized visible plain text used by that row's Copy/TTS surface—not
rendered HTML, hidden reasoning, the complete provider payload, or a
transcript-wide read. It is an empty string when an otherwise eligible settled
row has visible attachments or media but no plain text.

A thenable returned by onInvoke controls pending state. Repeated clicks on the
same extension/action/session/message target are suppressed while it is pending;
different targets may run concurrently. Throws, thenable-access failures, and
rejections from the current invocation are isolated and surfaced as a generic
failure. Once an invocation has settled, or its action is unregistered or
uninstalled, late callbacks cannot report a failure, refresh actions, clear a
replacement invocation's pending state, or restore stale focus. Core preserves
and restores the connected opener when possible after settlement. There is no Core
timeout, message/session mutation API, arbitrary row markup, extension-controlled
ordering, or overflow menu in V1.

Turn lifecycle events

A registered extension can react when a session turn observed by the current page
starts or reaches a terminal state:

js
const ext = window.hermesExt.register("desktop-companion");
const stop = ext && ext.events.on("turn:complete", event => {
  console.log(event.sessionId, event.streamId, event.status);
});

// Remove the listener when the extension no longer needs it.
if (stop) stop();

The supported event types are turn:start, turn:complete, turn:error, and
turn:cancel. The frozen event object always contains type, sessionId,
streamId, and timestamp (Unix seconds). Start events also contain
startedAt; terminal events contain endedAt and may contain status.
events.on(type, handler) returns an idempotent unsubscribe function, or null
for an unsupported type or non-function handler. An exception in one extension
listener is logged and does not prevent other listeners or the chat UI from
continuing.

The version-1 mapping is deliberately small: attaching a confirmed live session
stream emits turn:start; SSE done emits turn:complete; SSE cancel and
application errors classified as cancelled or interrupted emit
turn:cancel; other application errors and an unrecoverable live-stream
connection failure emit turn:error. Transport-only stream_end does not emit
a second terminal event.

Core suppresses reconnect duplicates so a recently observed
sessionId/streamId pair produces at most one start event and one terminal
event. sessionId is the owner of the original stream; server-side compression
or session rotation at completion does not change that lifecycle identity.
Terminal callbacks run after Core has cleared the original stream ownership,
applied the current session's immediate terminal transcript projection, and
returned the owned pane to its idle state. They do not wait for unrelated
follow-up work such as notifications, queued turns, or optional recovery
refreshes.

This is a page-local, live-stream notification surface. It does not replay
history, report turns that finish without this page observing their stream, or
provide token, tool, approval, or metrics events. It follows the same cooperative
trust model as register(id) and adds no sandbox or permission boundary. A
durable or global agent event stream is a separate Core contract. Ephemeral
/btw answer streams are not included in this version.

URL rules

Injected asset URLs are deliberately restricted:

  • must be same-origin paths
  • must start with /extensions/ or /static/ after manifest normalization
  • must not include a URL scheme, host, fragment, quote, angle bracket, newline,
    NUL byte, or backslash
  • must not contain dot-segments or dotfiles after percent-decoding

Allowed examples:

text
/extensions/app.js
/extensions/app.css
/extensions/app.js?v=1
/static/theme.css

Rejected examples:

text
https://example.com/app.js
//example.com/app.js
javascript:alert(1)
/api/session
/extensions/app.js#fragment

These restrictions keep the existing Content Security Policy intact and avoid
turning the extension hook into a third-party script loader. Invalid configured
URLs are ignored rather than injected.

Trusted local sidecars

Manifest-bundled extensions may integrate with a trusted local sidecar process,
such as a desktop companion listening on http://127.0.0.1:17787. The injected
extension JavaScript can talk to that sidecar directly from the browser, and
WebUI diagnostics still use that direct browser path. WebUI may also proxy the
same sidecar through a fixed per-extension sidecar path after explicit persisted
user consent. WebUI does not create arbitrary extension-owned backend routes.

Loopback sidecar origins are already included in WebUI's enforced CSP
connect-src directive:

text
http://127.0.0.1:*
http://localhost:*
http://ipc.localhost
ws://127.0.0.1:*
ws://localhost:*

The wildcard ports above cover any loopback port, including
http://127.0.0.1:17787. For a trusted non-loopback origin that you explicitly
control, append the exact origin with HERMES_WEBUI_CSP_CONNECT_EXTRA before
starting WebUI:

bash
HERMES_WEBUI_CSP_CONNECT_EXTRA=https://companion.example.internal HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json ./start.sh

HERMES_WEBUI_CSP_CONNECT_EXTRA accepts space-separated http(s):// or
ws(s):// origins only. It rejects paths, directive injection, and invalid port
numbers. Avoid wildcard or remote origins unless you fully control the target;
extension JavaScript runs with the logged-in WebUI session's authority.

Loopback sidecar declarations

Sidecar declarations are sanitized before they appear in diagnostics:

  • only "type": "loopback" is supported
  • origin must be an http or https origin on 127.0.0.1, localhost, or
    [::1]
  • origin must not include a username, password, path, query string, or fragment
  • health_path is optional and defaults to /health
  • when present, health_path must start with / and must not contain a scheme,
    host, query string, fragment, quotes, control characters, backslashes, empty
    segments, whitespace, or path traversal

Invalid sidecars are skipped with a stable warning code such as
sidecar_origin_rejected, sidecar_type_unsupported,
sidecar_health_path_rejected, or sidecar_invalid. Raw rejected origins and
paths are never returned by the status endpoint. If health_path is omitted,
diagnostics use /health; if health_path is present but invalid, the sidecar is
skipped rather than probed.

Remote images (`img-src`)

The default policy allows images only from the WebUI itself plus data: and
blob:. Remote https: images are blocked so that a model reply cannot make
the browser contact an arbitrary server just by rendering (#7941). In chat a
blocked remote image shows as an "Open image · host" link instead. An extension
page that loads images from another origin needs the operator to allow it:

bash
# space-separated http(s) origins; optional *. subdomain wildcard and port.
export HERMES_WEBUI_CSP_IMG_EXTRA="https://images.example.com https://*.cdn.example.com"

The bare scheme https: restores the old allow-everything behaviour. The
same validation rules as HERMES_WEBUI_CSP_FRAME_EXTRA below apply, and the
allowlist also covers images inside chat replies and public share pages.

Embedding an external web app in an iframe

By default the WebUI's Content-Security-Policy only allows it to embed
same-origin content in an `` (the frame-src directive falls back to
'self'). An extension that wants to pin an external self-hosted web app — a
Grafana board, Vaultwarden, a personal dashboard — as a tab therefore needs the
operator to widen frame-src, opt-in, via an environment variable:

bash
# space-separated http(s) origins; optional *. subdomain wildcard and port.
export HERMES_WEBUI_CSP_FRAME_EXTRA="https://grafana.example.com https://*.dash.example.com:8443"

Rules and guarantees:

  • Only http(s) origins are accepted (an iframe src is always http(s)).
    Entries may include a *. subdomain wildcard and a port or * port; a path,
    a ws:///wss:// scheme, an invalid port, or any attempt to inject another
    directive is rejected and the whole value is ignored (with a logged warning).
  • This mirrors the existing HERMES_WEBUI_CSP_CONNECT_EXTRA knob (which widens
    connect-src for fetch/WebSocket); the two are independent.
  • It only governs what the WebUI page may embed. It does not touch
    frame-ancestors, which stays 'none' — so widening frame-src never lets
    another site embed the WebUI itself.
  • Default-off: with the variable unset, the policy is unchanged (same-origin
    iframes only).

An "external app tab" extension should document the exact origin(s) it needs so
the operator can set this knob deliberately, rather than assuming a wide-open
policy.

Static file serving

When HERMES_WEBUI_EXTENSION_DIR points at an existing directory, files under
that directory are available below /extensions/:

text
/path/to/my-extension/static/app.js  ->  /extensions/app.js
/path/to/my-extension/static/ui.css  ->  /extensions/ui.css

The static handler is sandboxed:

  • path traversal is rejected, including encoded traversal
  • dotfiles and dot-directories are not served
  • symlinks that resolve outside the extension directory are rejected
  • missing or invalid extension directories behave as disabled
  • manifest paths must be relative files inside the configured extension directory
  • malformed, missing, or oversized manifests are ignored without enabling unsafe URLs
  • failures return a generic 404 without exposing local filesystem paths

Security notes

Only enable extensions from directories you control. Extension JavaScript runs in
the WebUI origin and can call the same authenticated WebUI APIs as the logged-in
browser session.

For shared or remotely exposed installations:

  • keep HERMES_WEBUI_PASSWORD enabled
  • bind to loopback unless you intentionally expose the service
  • review extension code before enabling it
  • prefer small, auditable extension files
  • avoid serving generated or user-writable directories as extension roots

Registering a custom theme (skin)

Extensions can contribute a custom skin that appears in the native
Settings → Appearance skin picker, instead of bolting on a parallel theme
switcher. Call window.registerHermesSkin(descriptor) from your extension
script:

javascript
window.registerHermesSkin({
  name: 'E-Ink',            // display name (also the picker label)
  value: 'e-ink',           // optional stable key; slugified from name if omitted
  label: 'E-Ink',           // optional explicit picker label
  scheme: 'light',          // optional: force a light or dark base while selected
  colors: ['#000000', '#ffffff', '#555555'],  // up to 3 preview swatches
  tokens: {                 // CSS design-token overrides for this skin
    '--bg': '#ffffff',
    '--surface': '#ffffff',
    '--text': '#000000',
    '--accent': '#000000',
    '--border': '#000000'
    // ...any of the allowed tokens below
  }
});

The call returns true on success and false if the descriptor was rejected
(so an extension can detect and log a bad theme). Once registered, the skin
shows up in the picker, can be selected, and persists across reloads exactly
like a built-in skin. Registering the same key again updates it in place
(idempotent), which is what a live theme editor relies on while the user edits.

Use scheme when a skin is light-only or dark-only. Accepted values are
"light" and "dark"; any other value is ignored. This does not rewrite the
user's saved Theme setting (Light, Dark, or System Default). It only
controls the effective base theme class while that extension skin is selected,
so a dark editor skin is not mixed with light-mode code/table tokens, and a
light E-Ink skin is not mixed with dark-mode tokens.

Core does the security-sensitive work for you. Because token values are
written into CSS, every value is sanitized in core, once, so every theme
extension inherits the guard:

  • Allowed token names (anything else is dropped): --bg, --surface,
    --surface2, --surface-subtle, --text, --text2, --muted, --accent,
    --accent2, --accent3, --accent-contrast, --accent-hover,
    --accent-text, --accent-bg, --accent-bg-strong, --accent-rgb,
    --border, --border2, --hover-bg, --code-bg, --code-text,
    --sidebar, --sidebar-text, --user-bubble, --assistant-bubble,
    --success, --warning, --danger, --info, --link.
  • Allowed value shapes (anything else is dropped): hex colors, rgb() /
    rgba(), hsl() / hsla(), CSS color keywords, simple numeric-with-unit
    values (px/em/rem/%), and a bare RGB triple (e.g. 0, 0, 0 for
    --accent-rgb, which the app consumes inside rgba(...)). Values containing
    url(), expression(), semicolons, braces, or other CSS-injection vectors
    are rejected.
  • Reserved keys are protected — an extension cannot overwrite a built-in
    skin key (e.g. default, ares, graphite).
  • A descriptor with no valid tokens after sanitization is rejected entirely.
  • Skin scheme is constrained — only light and dark are accepted. Invalid
    scheme values are ignored rather than rendered into CSS.

This is the supported, forward-looking way for theme-pack and theme-creator
extensions to integrate with the built-in appearance system.

Registering a custom TTS engine

Extensions can contribute a text-to-speech engine that appears in the
Settings → TTS Engine dropdown alongside the built-ins (Browser / Edge /
ElevenLabs) and is used by both playback paths — the hands-free voice-mode
auto-read and the per-message "Listen" button. Call
window.registerHermesTtsEngine(descriptor):

javascript
window.registerHermesTtsEngine({
  id: 'voicevox',                 // [a-z0-9_-], not a built-in
  label: 'VOICEVOX (local)',      // shown in the dropdown (textContent — escaped)
  // synthesize(text, opts) -> Promise<ArrayBuffer | Blob | TypedArray> of audio.
  // opts carries the user's saved { voice, rate, pitch } (engine may ignore).
  synthesize(text, opts) {
    return fetch('http://127.0.0.1:50021/...', { /* ... */ })
      .then(r => r.arrayBuffer());
  }
});  // -> true on success, false if rejected

Rules and guarantees:

  • id must be slug-safe ([a-z0-9][a-z0-9_-]{0,31}) and may not shadow a
    built-in engine (browser, edge, elevenlabs) — those are reserved.
  • label is inserted with textContent, never innerHTML (no markup
    injection into the dropdown).
  • synthesize must return audio bytes (ArrayBuffer, Blob, or a typed array);
    core coerces to an ArrayBuffer and plays it through the same <audio>
    lifecycle as the Edge engine (including stop/rearm in voice mode). A rejected
    promise or empty/invalid result fails gracefully (toast on the Listen button;
    re-listen in voice mode).
  • Core owns selection, the dropdown option, and playback; the extension only
    produces audio. Re-registering the same id updates it in place.
  • Network note: if your engine calls a local server (e.g. VOICEVOX on
    http://127.0.0.1:50021), that request is a same-origin-policy / CSP
    connect-src concern like any extension network call — loopback is already in
    the default connect-src. Declare permissions.network_external honestly
    based on where it calls.

Extension authoring guidance

Extensions share the page with the WebUI app, so they should be additive and
reversible. Prefer small, well-scoped DOM changes that can be removed or hidden
without breaking the built-in Chat, Tasks, Settings, or session views.

Recommended patterns:

  • create extension-specific containers with unique IDs or class prefixes
  • add UI next to existing views instead of replacing large app containers
  • keep event listeners scoped to extension-owned elements where possible
  • preserve built-in navigation behavior and restore any view state you change
  • use hidden, aria-*, and extension-scoped CSS for panels or overlays
  • guard initialization so reloading or re-injecting the script does not create
    duplicate buttons, panels, timers, or event listeners

Avoid destructive mutations such as replacing document.body.innerHTML,
main.innerHTML, or other broad WebUI containers. Those patterns can remove or
mask the app's existing panels and leave normal navigation unable to recover
after an extension view is opened.

For custom pages, prefer adding a dedicated panel and toggling it alongside the
built-in views:

javascript
(() => {
  if (document.getElementById('my-extension-panel')) return;

  const panel = document.createElement('section');
  panel.id = 'my-extension-panel';
  panel.className = 'main-view my-extension-panel';
  panel.hidden = true;
  panel.textContent = 'My extension page';

  document.querySelector('main')?.appendChild(panel);

  function showPanel() {
    document.querySelectorAll('main > .main-view').forEach((view) => {
      view.hidden = view !== panel;
    });
  }

  // Wire showPanel() to an extension-owned button or menu item.
})();

If host CSS overrides [hidden], add an extension-scoped rule such as:

css
.my-extension-panel[hidden] {
  display: none !important;
}

Contributing to the extension library

To publish an extension in the vetted gallery, open a PR against
hermes-webui/hermes-webui-extensions
following docs/extension-entry.md
(entry layout, extension.json/manifest.json shape, and the capability +
best-practice conventions). Every entry PR runs the repo's CI validators and
safety scan before it can merge, and merged entries are published to the registry
that powers Settings → Extensions.

Minimal example

Create a local extension directory:

bash
mkdir -p ~/.hermes/webui-extension
cat > ~/.hermes/webui-extension/app.css <<'CSS'
.my-extension-badge {
  position: fixed;
  right: 12px;
  bottom: 12px;
  padding: 6px 10px;
  border-radius: 999px;
  background: #202236;
  color: #fff;
  font: 12px system-ui, sans-serif;
  z-index: 9999;
}
CSS
cat > ~/.hermes/webui-extension/app.js <<'JS'
(() => {
  const badge = document.createElement('div');
  badge.className = 'my-extension-badge';
  badge.textContent = 'Extension loaded';
  document.body.appendChild(badge);
})();
JS

Start WebUI with the extension enabled:

bash
HERMES_WEBUI_EXTENSION_DIR=~/.hermes/webui-extension \
HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/app.css \
HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/app.js \
./start.sh

Open the WebUI and confirm the badge appears.

Diagnostics

Authenticated administrators can inspect sanitized extension configuration at:

text
GET /api/extensions/status

The status endpoint is read-only and follows the normal WebUI authentication
rules. The same sanitized diagnostics are also shown in Settings → Extensions
for operators who prefer to inspect extension state from the browser. Installed
manifest entries can be enabled or disabled from that panel through the
authenticated POST /api/extensions/toggle endpoint. The toggle writes only a
WebUI-managed override in the WebUI state directory; it does not edit extension
manifests, fetch new extension assets, uninstall files, or add extension-owned
backend routes. Manifest entries with "enabled": false remain
manifest-disabled and cannot be re-enabled from WebUI.

The diagnostics return the same public asset URLs that can already be injected
into the HTML, plus coarse manifest status, per-extension effective state, asset
counts, sanitized declared loopback sidecars, and warning codes for rejected or
unavailable configuration. manifest.script_count and
manifest.stylesheet_count count accepted assets from the effectively enabled
manifest entries only; manifest.sidecar_count counts accepted enabled loopback
sidecars from the manifest. counts.script_urls and counts.stylesheet_urls
count the final post-env-merge URLs, while counts.sidecars counts the sanitized
sidecar list returned in sidecars. counts.manifest_extensions counts
sanitized manifest extension entries with valid IDs, and counts.user_disabled
counts installed manifest entries currently suppressed by the WebUI-managed
override. manifest.entry_count counts the loaded top-level manifest object and
effectively enabled extension entries that were inspected for injection, not
every extension object in the file. The endpoint and Settings panel do not
return HERMES_WEBUI_EXTENSION_DIR, resolved manifest paths, raw environment
values, rejected URL strings, rejected sidecar origins, rejected health paths, or
the override state-file path.

When sanitized loopback sidecars are present, Settings → Extensions renders a sidecar monitor card. The browser checks each declared health_url directly with fetch(..., { credentials: 'omit', cache: 'no-store' }) and a short timeout. A successful HTTP response is shown as healthy, a non-OK HTTP response as unhealthy, and CORS/network/timeouts as unreachable or blocked; raw health response bodies are never rendered. If a healthy response includes an optional top-level runtime object, the panel may parse it and render only allowlisted scalar fields such as sidecar, native_host, bridge, last_seen_at, and webui_origin. This keeps sidecar-specific diagnostics machine-readable without making WebUI depend on any one extension's private payload shape.

The same card also exposes proxy consent through POST /api/extensions/sidecar-proxy-consent and reports the fixed per-extension sidecar path /api/extensions/<extension-id>/sidecar/<relative-path>. WebUI strips Cookie, Authorization, and CSRF headers before contacting the sidecar, and sidecar Set-Cookie headers are stripped before the browser sees the response. WebUI does not create arbitrary extension-owned backend routes; the proxy surface stays on that fixed per-extension sidecar path.

When sanitized settings are present, Settings -> Extensions renders
browser-local controls for installed manifest entries. Save, reset, and clear
storage actions call window.HermesExtensionSettings; they do not call backend
storage routes and do not write WebUI settings.


GUIDELINES (`docs/GUIDELINES.md`)

Change guidelines — writing a PR that lands cleanly

Hermes WebUI is deliberately simple: Python on the server, vanilla JS in the browser, no build
step. That simplicity only survives if every change is scoped, verified, and complete. This
document is the distilled set of principles that make the difference between a PR that merges on the
first review and one that needs several rounds of back-and-forth. It applies to human and
AI-assisted contributions alike — but it is written especially for AI coding agents, because the
mistakes below are the ones they make most often and most confidently.

If you read nothing else, read the four root causes. The ten rules are just their enforcement.


The four root causes of a change that has to be redone

Almost every change that fails review fails for one of these reasons. Learn to catch them in your
own work before you open the PR.

  1. Fixing the instance instead of the class. The single most common one. A bug usually has
    more than one home — the same mistake lives at several call sites, across more than one backend
    or code path, in a companion endpoint, in a second layout, on more than one lifecycle exit. It's
    easy to fix the exact line the report points at and leave the siblings broken. The missed one is
    usually a different dimension than the one you were looking at (a second backend, an unusual
    layout, the cancel path, the multi-item case).

  2. Mistaking a proxy for proof. "The test passes," "the code contains the string," "that branch
    ran," "I mocked it and it returned the right thing" — none of these prove the user-visible
    behavior
    is correct. A test that would still pass if you reverted the fix is testing nothing.

  3. Reasoning about a value's content but not its lifetime. You get the value right but miss when
    it goes stale, who is responsible for cleaning it up, and what happens to it on every exit path
    (success, error, cancel, replace, shrink, concurrent access). Leaked resources, orphaned caches,
    stale reads, and "the decision used one value but the action used another" all live here.

  4. Letting the diff grow past the task. Extra changes you weren't asked for, or re-implementing
    something the codebase already centralizes (a fallback, a shared helper, an extension point).
    Every extra line is a new risk surface and makes the change harder to review and to trust.

A fifth applies only to visible changes: placing a control where the code is, not where the
user's attention should go.


The ten rules

1. Fix the class, not the instance.

Before writing the fix, search for every sibling — other call sites, producers and consumers,
alternate backends, companion endpoints, other layouts, every lifecycle exit — and fix at the
shared chokepoint. If you deliberately leave one out of scope, name it in the PR and say why.
One guard in the shared function is a smaller and more correct change than one guard per caller.
The chokepoint is the smallest boundary that contains the fault, not the widest one you can
reach: fixing the class means guarding every sibling of the bug, not disabling a whole stage or
pipeline to suppress one bad output. If your fix blocks more than the fault occupies, it has grown
past the task (rule 9).

2. Trace one authoritative value end-to-end.

Follow the value from input → normalize → decision → action → persist → cleanup, and use the
same resolved value at every stage. Don't let the code that decides see one value while the
code that acts uses the stale or un-normalized one — that split is a silent correctness bug.
Before you key a decision on a value, confirm it's authoritative: written at the point of intent
(a canonical kind field, a provenance marker stamped by the action itself, a recorded origin) — not
inferred from an id prefix, content shape, emptiness, or DOM state. Search for an existing canonical
field first; inference that happens to work on the case in front of you is the most common way a
guard misfires on a sibling. If no authoritative field exists, add a test with an adjacent case that
matches your heuristic and must not trigger.

3. When you can't confirm something, fail closed and say so.

For anything touching authority, capability, identity, or containment: if you cannot confirm it's
safe, deny it — don't take the permissive branch on uncertainty, and never report a failure as a
success. Treat "unknown", "absent", and "unverifiable" as distinct from "allowed." Stating "I could
not verify X" in the PR is good engineering; hiding it is how a security or reliability bug ships.

4. Enumerate the state-space before you edit.

Write down which dimensions the change touches — entry point, backend, item count (0 / 1 / many /
duplicate), lifecycle exit (success / error / cancel / replace / shrink / teardown), auth on vs off,
concurrency (two profiles or workers at once), input shape (empty / hostile / aliased) — and cover
each, or mark it out of scope on purpose. Most redo rounds are one un-considered dimension. Those
axes are generic; the ones that actually bite are subsystem-specific and are not obvious to a
first-time contributor. Before editing an unfamiliar subsystem, find its real variants: read the
closed PRs and review threads that touched the same code, where the discriminating dimensions
(every session-lineage kind, every replay/recovery source, each stream response shape) were already
enumerated. Reuse that list rather than inventing your own from the one case you were handed.

5. Assume inputs and check-then-use gaps are adversarial.

Input crossing a trust boundary can be crafted to break a naive check (odd delimiters, casing, YAML
aliases, path traversal). Filesystem and process state can change between the moment you check it
and the moment you use it, so validate at the point of use (hold a file descriptor/handle rather
than re-resolving a path). Scope caches by their complete identity or they leak across
profiles/sessions under concurrency. Validation is worthless if the thing you finally use isn't the
thing you validated.

6. A test must fail before your fix and pass after it.

Run your new test against the current code first and confirm it fails for the right reason; only
then apply the fix. Assert the observable behavior (the row updates, the secret is absent from
the log, the request carries the right value) — not a source string, and not through a mock that
stands in for the very thing under test. If the bug is about picking the right one of several items,
the test must include several items, or it can't catch picking the wrong one. When the issue ships a
reproduction — a session capture, a script, exact steps, an attachment — your test loads that,
not a fixture you rebuilt from your reading of it. A fix and a test written from the same wrong model
of the bug will base-fail and head-pass on the fixture forever while the real symptom sits untouched;
loading the reporter's actual scene is what breaks that agreement. A reproduction is whatever pins
the bug's shape
— a downloadable capture, but a fenced JSON structure, the field-level trigger
conditions, or exact steps bind it just as tightly. Bind your fixture to that shape: satisfy every
condition it names and let the assertion fail when any one is violated, instead of granting the
fixture a property the report never had so a guard will fire. Reconstruct the shape yourself only
when the issue genuinely leaves it unpinned — and then say so, and say what you assumed.

7. Name the owner of every piece of state and prove it's released.

For each resource or mutation you introduce (a cache entry, a lock, a temporary env change, a
loading flag, a DOM node, a pending-map entry), show it is cleaned up or invalidated on every
exit — success, error, cancellation, replacement, shrink/clear, and teardown — not just the happy
path. Happy-path testing structurally cannot see these leaks.

8. Fallbacks and defaults are contracts — extend the mechanism, don't copy it.

If your change means editing several parallel blocks identically, stop: the codebase almost
certainly has a mechanism for this (a fallback, a shared helper, an extension point) and you've
missed it. Add to the one canonical place. For example, new user-facing copy goes in the en locale
and rides the existing fallback — don't paste English into every locale block.

9. The diff is the task and nothing else.

Change exactly what the task requires. Anything else you noticed goes in the PR description as a
note, not into the diff. Before opening the PR, run the affected tests and a broader sweep of
neighboring tests (not only the ones you added), and remove every unrelated change.

10. A visible control costs attention on every future visit — place it accordingly.

Where a control lives should be decided by how often it's used and by where mainstream chat apps put
the equivalent — not by where your diff already is. Rare per-item actions belong in an overflow/⋮
menu; global or data actions belong in Settings; only genuinely daily-use controls earn a spot on a
hot surface like the composer. Then verify it visually: capture desktop and narrow-viewport,
before and after, and confirm nothing is clipped, no overflow-collapse is tripped, and no hover-only
affordance is stranded on touch.


Show your work in the PR description

The fastest review is one where the reviewer can see that the above is handled instead of having
to discover the gaps. Make your PR description carry the evidence:

  • The siblings you found for rule 1/4 — and any you deliberately left out of scope.
  • Proof the test bites — that your new test failed before the fix.
  • Verification run — the affected + neighboring tests, not just your new ones.
  • Before/after images for any visible change (desktop + narrow).
  • What you could not verify — an explicit list. An admitted gap is fine; a hidden one is a bug
    waiting to be found in review.
  • Who owns the truth for any claim the repo doesn't — browser behavior, a provider's API, a
    registry, an OS convention. Name the owner and check your proof is one they'd accept: in-page
    automation proves page behavior, not that the browser didn't swallow the event first; a mock
    proves your intent, not the provider's contract; a synthetic fixture proves nothing about the live
    system. The failure mode here isn't knowing you couldn't verify — it's thinking you did.

Following this imperfectly still helps enormously, because the reviewer can see exactly where your
coverage stops instead of finding it the hard way.


Companion to AGENTS.md, CONTRIBUTING.md, and
docs/CONTRACTS.md. For UI/UX specifics see docs/UIUX-GUIDE.md
and DESIGN.md.


ISSUES (`docs/ISSUES.md`)

Upstream Issues — Root Cause Analysis

#1256: Browser tools fail with "Playwright not installed"

Root Cause

The check lives in hermes-agent (upstream), not hermes-webui:

text
hermes-agent/tools/browser_tool.py → check_browser_requirements()

check_browser_requirements() does not recognize CDP (Chrome DevTools Protocol) mode — it only looks for a local Playwright/Puppeteer install. When the agent runs in CDP mode (connecting to an existing browser), the check still fails.

WebUI side

The WebUI already passes CLI_TOOLSETS correctly per-request. The enabled_toolsets field in the cron/chat config is dynamic and works as intended.

Fix required

The fix must happen in hermes-agent/tools/browser_tool.py:

  • check_browser_requirements() should skip the Playwright check when CDP mode is configured
  • Or add a BROWSER_MODE=cdp env var that bypasses the local browser requirement

Workaround

Use CLOUD_BROWSER=true or configure browser.base_url to point to a remote CDP endpoint. This bypasses the local Playwright requirement.


UIUX GUIDE (`docs/UIUX-GUIDE.md`)

UI/UX Guide

This document summarizes UI/UX principles that are already visible in the
repository. It is a contributor guide, not a new design proposal. Source
documents include DESIGN.md, README.md,
THEMES.md, docs/ui-ux/index.html,
docs/ui-ux/two-stage-proposal.html, and
design comments in static/style.css.

Use this guide when a change touches layout, chat rendering, composer chrome,
navigation, theme/skin behavior, responsive behavior, or visual hierarchy. For
purely backend changes, use the runtime/state contracts instead.

Product shape

Hermes WebUI is a browser workbench for Hermes Agent with near-CLI parity and a
simple implementation shape: Python on the server, vanilla JavaScript in the
browser, no build step, no bundler, and no frontend framework.

The primary layout is three-panel:

  • left sidebar for sessions and navigation,
  • center panel for chat,
  • right panel for workspace file browsing and previews.

Model, profile, workspace, attachments, voice input, context usage, Stop, and
Send controls live in the composer footer. Settings and session-level tools live
in the Hermes Control Center. Preserve this shape unless the change explicitly
justifies a different interaction model.

Core feeling: calm developer console

The main artifact is the conversation. Tool calls, thinking traces, context
compaction records, token usage, runtime status, and other internals are useful,
but they are transcript metadata. They should sit below user and assistant prose
in visual priority.

Prefer:

  • quiet surfaces,
  • clear spacing,
  • restrained accent use,
  • progressive disclosure for debugging detail,
  • legible text over decorative chrome.

Avoid turning the interface into a demo page of colorful cards. Errors,
approvals, and other action-required states may be prominent because the user
must notice and respond to them.

Conversation hierarchy

A chat turn should read as one coherent story:

  1. User message: right-aligned, compact bubble.
  2. Assistant content: left-aligned, prose-first, not a heavy bubble.
  3. Tool, thinking, progress, and context traces: quiet disclosure rows inside or
    adjacent to the assistant turn.
  4. Raw logs and verbose details: hidden until explicitly expanded.

Do not render every internal event as a first-class chat card. A turn that used
many tools should summarize the work as inspectable activity, not make the user
read a stack of unrelated-looking cards.

Tool, thinking, and activity traces

Tool cards are debug event rows, not chat messages. Show the icon, name, short
target or preview, and status first. Arguments, result snippets, and long logs
belong behind expansion, with result snippets truncated and full output behind a
show-more affordance where needed.

Thinking and context cards should share the quiet metadata visual family. They
should not overpower assistant prose. Collapsed activity summaries should be
terse, for example Activity: 4 tools, and should not duplicate the thinking
area, list every tool name in the summary, or add redundant trailing count
badges.

Visible interim assistant progress is part of the live conversation timeline,
not raw debug detail. Compact Activity may collapse tool arguments, long tool
results, and low-level reasoning detail, but it must not make concise
user-visible progress text available only inside a collapsed disclosure.

A live Thinking card follows its own tail while reasoning streams: new text
scrolls into view when the card is open at its bottom, exactly like the message
pane. Scrolling up inside the card holds the reader's place (follow resumes on
returning to the bottom), and collapsing the card mid-stream pauses the inner
follow — expanding again restores it. Settled history cards never auto-scroll;
they open at the top like any other persisted content, and opening one must not
give it follow state: a persisted card that acquired a latch would chase its own
tail on any later re-render, such as a 1280px-to-390px reflow.

Opening a card is a transition, not an instant: the body animates from zero
height, so its position only becomes meaningful once max-height finishes
(opacity ends earlier and means nothing here). While that animation runs the
card is "settling" — no follow state is seeded and no streaming delta may move
it, so a reader who opens a long card and scrolls up is never yanked to the
bottom by text arriving mid-animation. A card closed again before the animation
ends stays unbound and re-measures on the next open.

Follow intent survives rebuilds in both directions: a collapsed following card
carries its latch across a scene-update rebuild and resumes follow when reopened
(never re-measured at its top and put on hold), and a rebuild that interrupts a
card's very first open lets the replacement body finish the open — it seeds
follow state from its real position instead of staying follow-less.

Automatic compression is a live-only context barrier, not a special branded
tool card. Render it as a centered, non-interactive divider with quiet horizontal
rules: Compressing context while the compression barrier is active and
Context auto-compressed when the agent has continued or the compression
completion event arrives. Do not give it a caret, click target, leading status
dot, or standalone running badge. In settled final history, remove live-only
automatic compression rows unless they explain a visible recovery or error
state.

The existing two-stage proposal in docs/ui-ux/two-stage-proposal.html records a
compatible direction for long turns: live work can be grouped as a worklog, then
settled history can collapse while the final answer reads as the calm
conclusion. Treat that page as an existing proposal, not as shipped behavior
unless the code and tests prove it is implemented.

Typography and content

Use three explicit font tokens:

  • --font-ui: shell chrome, controls, composer, labels, and ordinary UI text
  • --font-conversation: user/assistant message prose; by default this is
    var(--font-ui) in static/style.css
  • --font-mono: code, file paths, command lines, tool payloads, technical logs,
    and terminal output

Use semantic tokens for typography. Keep prose on --font-conversation by
default so it tracks --font-ui whenever a skin intentionally retunes UI type.
Override --font-conversation only for a skin that intentionally wants a
distinct prose face.
Conversation prose must remain the system sans by default. Do not introduce a
global conversation serif through the --font-conversation token or
selector-level overrides without explicit design approval plus code and test
evidence; keep distinct editorial prose typography explicitly opt-in or
skin-scoped.
Avoid hard-coding selector-level font stacks when a token already carries the
intent.

Keep scale tight. Avoid introducing near-duplicate one-off font sizes, colors,
radius values, or spacing values when an existing token works.

Color, depth, and shape

Use one accent at a time. Semantic colors are for semantic state: success,
warning, error, and info. Do not mix many bright colors decoratively in the same
viewport.

Use almost no shadows in the transcript. Reserve shadows for popovers,
dropdowns, modals, and floating controls. Chat cards should usually use either a
subtle border or a subtle tint, not aggressive combinations of both.

Avoid stacks of nested rounded rectangles. Rows and list items should feel
compact; panels and cards may be slightly rounder; true pills are reserved for
chips and badges.

Composer and controls

The composer is the command surface. Keep it legible, stable, and focused:

  • no theatrical hover scaling for routine controls,
  • no ambient chrome that crowds the model/workspace/profile controls,
  • no new footer buttons on tight layouts without a clear value tradeoff,
  • keep Stop/Send and context feedback easy to find while composing.

When adding a control, consider where users will find it on both wide desktop and
mobile. If a setting or quota/control surface does not fit in the composer, route
it through the appropriate Control Center panel instead of squeezing the footer.

Composer sizing

The composer grows with its content up to a 200px cap. Where the browser supports
field-sizing: content (the stylesheet sets it on textarea#msg, with
field-sizing: fixed while the placeholder shows) CSS owns that; everywhere else
the JavaScript fallback (autoResize() in static/messages.js) measures, and it
runs on every keystroke - so treat it as a hot path and keep these invariants when
touching it (regression coverage:
tests/test_long_session_composer_typing_latency.py, which derives every
dimension from static/style.css, and
tests/test_issue5514_composer_grow_scroll_pin.py):

  • A single-row append that already fits its box skips the height round trip. That
    round trip reads scrollHeight, which forces a synchronous layout of the whole
    document, so its cost grows with the rendered transcript - this is the
    long-session typing-lag class. Do not remove the skip.
  • The skip's ceiling is the textarea's natural ONE-ROW height (line-height +
    vertical padding + borders) or the CSS min-height, whichever is larger.
    Compare against that natural row, never against min-height alone: the natural
    row follows the appearance font size (44px at the 16px default, 48px at
    data-font-size=large, 51px at xlarge), while min-height stays 44px, so a
    min-height-only ceiling silently disables the skip for the larger sizes.
  • Everything else still fully remeasures: an oversized composer, a replacement, a
    shrink, a multi-line append, and session/draft restore.
  • Non-pixel computed values (a percentage, calc(), auto) fail closed to the
    full resize rather than enabling the skip from a bogus pixel parse.

Responsive behavior

Mobile is not an afterthought. The repository documents a responsive layout with
a hamburger sidebar, mobile-accessible top tabs, a right-edge file slide-over,
full-height chat/composer behavior on phones, and touch-friendly controls.

For UI changes, verify the relevant states:

  • wide desktop,
  • ordinary laptop width,
  • narrow/mobile width,
  • open and closed side panels when relevant,
  • long chat content and live streaming when relevant.

Controls should remain usable at touch sizes, and mobile navigation should not
steal chat height unnecessarily.

Keyboard and assistive-tech behavior on closed mobile panels

On the compact off-canvas bands, the closed sidebar and workspace drawer stay
laid out so their slide-out can animate, which would otherwise leave their whole
subtree in the tab order. Three layers keep a closed mobile panel out of the
keyboard sequence, and changes to this area must keep all three:

  1. The panel takes the inert attribute the moment it closes
    (_setPanelInert() in static/boot.js, set by closeMobileSidebar() and
    _setWorkspacePanelMode()), so the 250 ms closing window is inert too — not
    just the settled state.
  2. visibility:hidden, delayed past the slide-out
    (visibility 0s linear .25s in static/style.css), removes the settled
    subtree from the tab order without cutting the transform transition.
  3. pointer-events:none keeps the parked panel from swallowing clicks aimed at
    the chat underneath it.

Focus handling when a mobile panel closes out from under a focused control
(_releaseFocusFromClosedPanel()) follows the same split as the desktop drawer:

  • an explicit dismiss (the "Close menu" X, the overlay, the drawer's Close,
    tapping outside the drawer) returns focus to the control that opened the panel
    — the hamburger, or the band-appropriate workspace toggle;
  • content-selection closes (picking a session or a panel item) leave focus on
    the composer, which those paths already move it to.

Browser probes that drive real key events are the only coverage that sees this:
a computed-style or tabIndex assertion cannot observe the browser refusing or
stranding a focus() call, and a static source assertion cannot observe a path
the probes never take. Keep both halves when adding a case.

Themes and skins

Theme and skin work should use the existing variable system. THEMES.md points
to the core palette variables in static/style.css; skin comments in the CSS
show the expected pattern for full palette rewrites and accent-only changes.

Current implementation has two appearance axes, sourced from static/boot.js:
theme is only light, dark, or system and resolves to the .dark class
for dark mode; skin is a separate axis applied with data-skin and currently
includes default, ares, mono, slate, poseidon, sisyphus,
charizard, sienna, catppuccin, nous, and geist-contrast / Geist Contrast. slate is both an active skin
and a legacy theme-name migration target; solarized, monokai, nord, and
oled are legacy theme names mapped to current theme/skin pairs. Do not follow
stale data-theme-only guidance without first proving the current
static/boot.js, static/index.html, and static/style.css contracts still
support it.

Do not hardcode new colors, radii, shadows, or typography values into isolated
components when a token or existing variable can carry the intent. If a token is
missing, explain why a new one is needed.

Evidence expected for UI changes

For any interface or interaction change:

  • include before/after images or a short video,
  • mention the tested viewport sizes and responsive states,
  • reference the affected visual inventory or design source when applicable,
  • add or update tests for behavior, state persistence, or regression-prone DOM
    structure where practical,
  • keep stable class or data hooks when they help future visual regression tests.

Do / don't summary

Do:

  • keep the conversation primary,
  • collapse noisy internals by default when settled,
  • make debugging details accessible without making them visually dominant,
  • use existing tokens, variables, and component patterns,
  • protect action-required states such as errors and approvals.

Don't:

  • make every tool call look like a separate chat message,
  • add decorative color or motion without a user-facing reason,
  • introduce a frontend framework, bundler, or build step for ordinary UI work,
  • hide important recovery, error, or approval state,
  • treat proposal mockups as shipped behavior without code/test evidence.

Advanced Chat Setup (`docs/advanced-chat-setup.md`)

Advanced chat setup

Two optional features for self-hosted Hermes WebUI deployments. Most users need neither — the defaults (in-process chat, no prefill) work out of the box.

Session recall prefill

WebUI can attach ephemeral prefill messages to new browser-originated
agent turns. This is useful when a deployment already has a local recall or
router script for Joplin, Obsidian, Notion, llm-wiki, or another third-party
notes source and wants browser chat to know where durable context lives.

Prefer a compact router-style prefill (for example, "Joplin has the durable
project context; use the available notes/search tools before answering
detail-dependent questions") instead of dumping the full note corpus into every
new browser session. The prefill should point the agent toward retrieval; the
notes/search tools should provide the specific facts on demand.

Static JSON remains supported through prefill_messages_file or
HERMES_PREFILL_MESSAGES_FILE. For dynamic recall, opt in explicitly with a
WebUI-specific script hook:

yaml
webui_prefill_messages_script:
  - python3
  - /path/to/notes_recall.py
webui_prefill_messages_script_timeout: 5

or:

bash
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT="python3 /path/to/notes_recall.py" \
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 \
./ctl.sh restart

The script may print either an OpenAI-style JSON message list, a JSON object with
a messages list, or plain text; plain text is wrapped as one user prefill
message so dynamic recall text becomes ordinary context instead of an extra
system instruction. If the hook must provide system-level guidance, emit JSON
messages with an explicit role: "system" entry instead. Script output is capped
at 256 KiB before parsing. Parsed prefill context is then bounded by
webui_prefill_context_max_chars or HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS
(default: 12,000 characters; set to 0 to disable). When a dynamic script
exceeds the budget and a compact static prefill file is configured, WebUI falls
back to that file. If no compact fallback is available, WebUI injects a short
retrieval instruction instead of sending the oversized note/body payload with
every new browser turn. The browser only receives a compact status event
(source, label, message count, compaction metadata, and redacted errors),
never the prefill message bodies.

Session title generation

Hermes WebUI derives a provisional session title from the first user message
and, after the first response, may call an LLM to generate a better title
(and periodically refresh it for long sessions).

For structured messages containing text and native images, title generation
uses the user text without flattening or modifying the stored message. Title
comparison and title-model inputs remove the internal [Workspace::v1: ...]
prefix and one terminal [Attached files: ...] or
[Attached files for this steer: ...] suffix separated by a blank line.
For structured content, this cleanup applies to the first text part that
provides title content. Literal legacy [Workspace: ...] text and later text
parts remain unchanged. Initial generation, explicit regeneration, and adaptive
refresh use this title-specific cleanup.

Background generation requires user text and a substantive assistant response.
It recognizes the sanitized provisional title as well as the existing raw
placeholder, so internal metadata does not make an image-containing turn look
manually titled. Image-only or metadata-only content does not provide title
text. Existing manual-title protection and the title-generation setting still
apply; this cleanup does not rewrite the transcript or native image parts.

Automatic title-generation LLM calls honor the active Hermes profile's
auxiliary.title_generation.enabled setting (default: true):

yaml
auxiliary:
  title_generation:
    enabled: false

When disabled:

  • the provisional first-message title stays in place and is never replaced
    or overwritten by an automatic LLM call or local fallback;
  • the periodic adaptive refresh is skipped;
  • the explicit "regenerate title" action returns a
    title_generation_disabled response instead of calling a title model.

The WebUI's auto_title_refresh_every setting remains a separate control for
periodic refreshes of already-generated titles; it does not re-enable
automatic generation when the auxiliary flag is off.

Pinning the title language

auxiliary.title_generation.language pins the language generated titles are
written in, whatever language the conversation itself is in:

yaml
auxiliary:
  title_generation:
    language: Japanese

A nonblank value is read once per generation attempt and drives both halves of
that attempt. The prompt instruction becomes Write the title in <language>.
in place of the default "match the language of the user question" rule, and the
post-generation drift check is retargeted to agree with it. Both title routes
honour the pin: the auxiliary-client route and the active-agent route.

Retargeting the validator is the point. The drift check exists to reject a
title whose language wandered away from the conversation (issue #3293), and on
a pinned install that same check would reject the pinned title the prompt had
just asked for. How a generated title is validated therefore depends on the
pin:

  • A pin the script map recognises (Japanese, Russian, Amharic,
    Bengali, Brazilian Portuguese, pt-BR) is checked against that
    language's script. The map covers the Latin, Cyrillic, CJK, Arabic, Hebrew,
    Greek, Devanagari, Thai, Georgian, Armenian and Ethiopic scripts, and the
    major Indic and South-East Asian ones.
    A title substantially outside it is still rejected, so an English pin
    rejects a CJK title and a Japanese pin rejects a Cyrillic one. A CJK pin
    keeps borrowed Latin terms (Python, WeChat Pay) as long as the title
    also holds at least two CJK characters, the same exemption the
    conversation-based check applies. "Substantially outside" means more than
    a third of the title's letters, summed across every other script. Styled
    alphabets such as mathematical bold, circled, enclosed or fullwidth letters
    count as the plain letters they decompose to; Roman numerals and circled
    digits are not letters and are left out of the count.
  • A pin the script map cannot resolve, and no pin, both keep the
    original behaviour: the title is checked against the language of the
    conversation's opening message. A pin outside the map therefore still
    changes the prompt, and a title that follows it into a script the
    conversation does not use is rejected as drift. Pin a language the map
    knows to get cross-script titles.

A language written in two scripts in majority use accepts either by default:
Punjabi accepts Gurmukhi and Shahmukhi (Arabic script), and Mongolian
accepts Cyrillic and the traditional Mongolian script. Serbian, Bosnian and
Uzbek have no default, because Cyrillic and Latin are both in wide use, so a
bare pin naming one of them keeps the conversation check.

A script qualifier narrows a recognised language to one script. It can be an
ISO 15924 code in a BCP 47 tag (pa-Arab, pa-Guru, mn-Mong, kk-Latn,
sr-Latn, zh-Hant) or an English script name beside the language
(Punjabi (Arabic), Malay (Jawi), Mongolian (Traditional),
Serbian (Cyrillic)). A qualifier is also how a minority script opts in: bare
Kazakh accepts Cyrillic only, and kk-Latn accepts Latin.

Anything ambiguous fails closed to the conversation check instead of widening
what is accepted:

  • a qualifier on a language the map does not know (Klingon-Latn, xx-Latn,
    Klingon (Arabic));
  • a language named only inside brackets after a word the map does not know
    (Klingon (English), Русский (Russian));
  • a pin longer than 256 characters once surrounding whitespace is trimmed,
    or 64 after normalisation;
  • two different qualifiers (English-Latn-Cyrl, pa-Arab-Guru), including
    two scripts validated the same way (ja-Hira-Kana,
    Chinese (Simplified, Traditional)), or Latin beside
    another script (Cyrillic Latin); equivalent ones collapse
    (pa-Arab-Aran, Japanese (Kanji, Hani));
  • a script code in a tag that the table does not know (ja-Zyyy);
  • two languages (English French);
  • a two-letter code outside a BCP 47 tag (pt (Brazil), No preference),
    because such codes collide with region codes and ordinary words. Write the
    language name or a tag instead: Portuguese (Brazil) or pt-BR.

Language lookup is diacritic-insensitive. In a BCP 47 tag the language is the
first subtag. A POSIX locale's encoding and modifier are ignored
(en_US.UTF-8), except that every script named in the modifier qualifies
(be_BY@latin, sr_RS@latin; pa_IN@arabic-gurmukhi conflicts), and so does
a piece of the encoding that is exactly a script code (en-Latn.Cyrl
conflicts). Otherwise a
language name counts anywhere outside brackets, so Francais, Français,
Traditional Chinese and Brazilian Portuguese resolve. The same rule
applies to languages whose names are also script names (Arabic, Greek, Thai,
Latin and others), so Egyptian Arabic and Modern Greek resolve, and so do
Klingon Arabic and Klingon-Latin: the unknown word is read as a modifier of
the named language. Klingon (Arabic) stays unresolved, because a bracketed
word only qualifies. A word in brackets only
qualifies: Tamil (Arabic) is Tamil in Arabic script, and mn (Mongolian) is
Mongolian because the code and the name agree. A language's own name for itself
is mostly not recognised, and a bracketed English name beside it does not
rescue it: Русский (Russian) and Klingon (English) are unresolved. Write the
English name or a tag instead: Russian or ru.

A pin longer than 256 characters once surrounding whitespace is trimmed, or
64 characters after
normalisation (lowercasing, diacritic folding, punctuation to spaces), is not
parsed; it is unresolved, so the conversation check applies, and the title
prompt leaves it out.

The pin affects session titles only. It does not change the language the
assistant replies in, and it has no effect when
auxiliary.title_generation.enabled is false, since no LLM title is
generated at all in that case.

Invalid model output and title recovery

A title model occasionally replies with an options menu instead of one title
(for example Good title options: "A", "B"). WebUI rejects structurally
multi-candidate replies — a menu preamble followed by two or more list
entries, semicolon/newline-separated candidates, explicitly quoted or
bulleted alternatives, or at least three short standalone comma-separated
alternatives — instead of persisting the raw menu as the title. A plain
two-part comma phrase such as Title Suggestions: OAuth Tokens, Explained
is ambiguous, so it stays valid. A comma joining grammatical clauses or a
comparison within one title (for example
Title Suggestions: Compare REST, GraphQL and gRPC) does not
prove a menu; neither do delimiters inside quoted terms.
Title Suggestions: Comparing "REST" and "GraphQL" stays valid.
A preamble with a single remaining phrase (for example
Title Suggestions: Migration Strategy) is kept, since that is a legitimate
title.

While a rejected reply leaves the automatic title unresolved, the provisional
title stays in place and the next completed exchange is used as the source for
a retry, so a session that opens with a warm-up message can still get a real
title from the substantive request that follows. The same recovery applies to
sessions that already persisted a menu-style title before this behavior
existed: they re-enter self-heal on the next turn. Manual renames always win;
recovery never overrides a user-set title. An unfinished latest turn is never
paired with an older assistant response, including during adaptive refresh.

After compression rotates a session ID (A→B), background title events target
the continuation directly — session_id and target_session_id carry B, and
stream_owner_session_id carries the original SSE owner A. The browser
listener accepts either identifier: a reattached B tab and an A tab that
rotates to B both apply the title, fencing on expectedCurrent so a manual
rename is kept. A title model that keeps returning unusable output is capped
at 3 recovery exchanges per session before it stops retrying. stream_end
still closes the original stream.

Session ID in the system prompt

WebUI tells the agent which surface it is running on (source, profile and
workspace) in the system text of each turn. The session ID is not part of
that text by default. It is different for every chat, so with it two new chats
never send the same system text, and a provider or local backend that caches
the prompt prefix has to read the rest of the request again for every new
chat.

If a skill, plugin or prompt of yours needs the model to know its own session
ID, turn it back on in config.yaml:

yaml
webui:
  pass_session_id: true

true, yes, on and 1 are accepted; anything else, or no key, leaves it
off. This mirrors Hermes Agent's own pass_session_id option, which is also
off by default. When it is on, the ID is the last line of the system text
(- Session ID: ), so everything before it is still the same across
chats.

Gateway-backed browser chat

By default, browser chat runs through WebUI's in-process legacy runtime. Advanced
self-hosted deployments can opt into routing new browser turns through a running
Hermes Gateway API server while preserving the existing WebUI /api/chat/start
and /api/chat/stream browser contract:

bash
HERMES_WEBUI_CHAT_BACKEND=gateway \
HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:8642 \
HERMES_WEBUI_GATEWAY_API_KEY=... \
./ctl.sh restart

Gateway-backed approval prompts need one more explicit opt-in because they use the Gateway runs API path:

bash
HERMES_WEBUI_CHAT_BACKEND=gateway \
HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:8642 \
HERMES_WEBUI_GATEWAY_API_KEY=... \
HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
./ctl.sh restart

Use this when the connected gateway advertises approval support and you want tool approval cards to appear in WebUI. Without HERMES_WEBUI_GATEWAY_USE_RUNS_API=true, gateway chat stays on the legacy chat-completions transport and approval-capable commands can remain pending in the agent without a WebUI approval card.

On the runs API path the turn is executed by the Gateway, so restarting WebUI does not stop it. WebUI stores the Gateway run_id on the pending turn (and submits it with an Idempotency-Key so the Gateway keeps a durable run record). On startup, WebUI reattaches to every such run by polling GET /v1/runs/{run_id} until it settles, then writes the real final answer into the session instead of a "Response interrupted" marker. Stop still cancels a reattached run, and a pending approval is shown again. Token-by-token output from before the restart is not replayed; the reattached turn shows only the final answer. If the Gateway no longer knows the run (for example, it restarted too and the run was interrupted), the turn ends with an error message instead. The legacy chat-completions transport cannot reattach: its turn ends when the WebUI process that holds the HTTP stream exits.

On the runs API path WebUI sends the session's earlier user and assistant turns to the Gateway as the run's conversation_history. Some transcript rows are shown in the conversation but not sent; they are the kinds of row the in-process backend also leaves out: error messages (a provider error, or the "Task cancelled." marker); a turn that left no visible text, such as one stopped while the model was still reasoning or calling a tool, or a reply that carried only reasoning; and a prompt that WebUI restored into the transcript after its turn was interrupted, unless the next row sent is its answer and it follows an assistant turn or opens the history, where it is the question that answer replies to. Where a stopped turn left a partial answer in the transcript, that text is sent, so the model can continue from it. An interrupted turn is reconciled into the model-facing context at the moment of cancellation — the prompt and the partial text the browser saw — whether the cancel came from WebUI's Stop or the Gateway itself, and the next turn's conversation_history carries that snapshot. When a stopped turn is regenerated instead, the retained prompt is sent once (as the new run's input, not duplicated in the history) and the writeback saves a single user row for it.

Live runs-API turns are also guarded while they stream. Each events connection carries a ~120s watchdog budget: a stream that delivers nothing but keepalives for that long is treated as stalled (keepalives are liveness, not progress), and the same budget bounds the per-read wait so a byte-silent connection surfaces within roughly one interval instead of pinning the configured 600s read timeout. When the watchdog trips, or the stream drops, the durable run status (GET /v1/runs/{run_id}) is the success arbiter: terminal status finalizes the turn (a non-empty durable output wins over the streamed text; with an empty output the already-streamed partial is kept), a still-running status reconnects the events stream from the last seen event, and waiting_for_approval surfaces the pending approval card from the status payload. A durable-status 404 gets a small immediate re-probe grace and then fails the turn closed rather than spinning. When the durable status resolves cancelled or interrupted, the streamed partial answer is persisted into the session before the browser-facing cancel event is emitted.

When YOLO is enabled for a gateway-backed browser session, WebUI approves every
approval already parked for that session: Runs API prompts are relayed by their
exact run_id and mirror token, and local/no-run waiters are all released. It
then automatically answers later Runs API approval requests while the WebUI
session flag remains active. The flag is committed only after every currently
parked remote relay succeeds; a later prompt that races that unconfirmed drain
remains visible instead of being speculatively auto-approved. The handoff is
also shared with local approval admission: a local waiter arriving after
the current drain snapshot waits for the same session handoff and is released
immediately if YOLO has committed, rather than being parked behind an enabled
session. This is client-managed compatibility behavior: the current Runs API has
no session-YOLO toggle, so a request briefly reaches the approval boundary before WebUI answers
it, and Agent-owned policy such as unrestricted computer-use mode is unchanged.
Native API session YOLO is tracked in Hermes Agent PR #61946.

HERMES_WEBUI_CHAT_BACKEND is intentionally strict: only gateway,
api_server, or api-server enable the bridge. Generic truthy values such as
1 or true are ignored so existing deployments do not change execution
ownership accidentally. If HERMES_WEBUI_GATEWAY_API_KEY is omitted, WebUI falls
back to API_SERVER_KEY when present. When Gateway returns HTTP 401, WebUI
reports a gateway_auth_error that points at this WebUI↔Gateway key mismatch
rather than showing the Gateway's generic provider-style "Invalid API key" body.
/api/health/agent also includes a redacted gateway_chat block so operators can
see whether gateway mode, base URL, and API-key presence are configured without
exposing the key value. That gateway_chat field is an operator diagnostic
payload only; it is not currently rendered as a user-facing health banner in the
browser UI.

Several profiles behind one Gateway

A turn uses the gateway URL and key of the session's own profile, not those of whichever profile the WebUI process loaded.

  • A profile that sets nothing of its own (no HERMES_WEBUI_GATEWAY_BASE_URL, HERMES_WEBUI_GATEWAY_API_KEY or API_SERVER_KEY in its own .env, no webui_gateway_base_url in its config.yaml) uses the shared endpoint the WebUI process was given, URL and key together. That includes values exported by the launcher and values the default profile's ~/.hermes/.env put into the process environment. This is what a single-profile setup sends, so named profiles on a shared Gateway keep working unchanged.
  • A profile can override the URL and the key independently. The half it does not set comes from the shared pair above. A profile's own URL is HERMES_WEBUI_GATEWAY_BASE_URL in its .env, or webui_gateway_base_url in its config.yaml (an exported HERMES_WEBUI_GATEWAY_BASE_URL takes precedence over config.yaml); a profile with no URL of its own keeps the shared URL. A profile's own key is HERMES_WEBUI_GATEWAY_API_KEY or API_SERVER_KEY in its .env.
  • The shared key is only ever sent to the shared Gateway. On a URL that is not the shared one, a profile sends its own key or none, never the launcher's or the shared key. On the shared URL the order is: the profile's own HERMES_WEBUI_GATEWAY_API_KEY, then the shared HERMES_WEBUI_GATEWAY_API_KEY (exported by the launcher, or set in the root profile's ~/.hermes/.env), then the profile's API_SERVER_KEY (the key of its own Gateway), then the shared key. A global HERMES_WEBUI_GATEWAY_API_KEY combined with per-profile HERMES_WEBUI_GATEWAY_API_KEY values therefore works: each profile with its own alias sends that alias.
  • A ${VAR} in a profile's webui_gateway_base_url is expanded from that profile's .env, read fresh from the file on every turn, plus any launcher export that did not come from another profile's .env. If the variable is not set anywhere, the turn ends with an error naming the unresolved variable and no request is sent.

The bridge is best used by operators who already run Hermes Gateway/API Server
locally and want browser-originated chat to use the same runtime/tool path as
messaging surfaces. Attachments, cancellation, approvals, and clarify prompts
still follow WebUI's current compatibility path and may not match every messaging
surface until the runtime-adapter migration is complete.


Docker (`docs/docker.md`)

Hermes WebUI — Docker setup guide

This is the comprehensive Docker reference. For a 5-minute quickstart, see the README Docker section.

TL;DR — pick one

Setup When to use File
Single-container (recommended) You just want chat working. WebUI runs the agent in-process. docker-compose.yml
Two-container You want isolation between gateway (CLI/Telegram/cron) and chat UI. docker-compose.two-container.yml
Three-container Two-container PLUS the dashboard for monitoring. docker-compose.three-container.yml
All-in-one image (community fork — third-party, not maintained by us) Podman 3.4 / multi-arch / supervisord-style preference. sunnysktsang/hermes-suite — see #1399 for the original discussion

Available Docker tags

The WebUI Docker image is published to ghcr.io/nesquena/hermes-webui with these tags:

Tag Channel Description
:latest stable The most recent stable release (from v* tags). Suitable for production.
:experimental experimental The most recent experimental release (from exp-v* tags). For early testing; may include breaking changes or unfinished features. Do not run in production.
:X.Y / :X.Y.Z stable Pinned stable releases (e.g., :1.5, :1.5.0).
:X.Y / :X.Y.Z experimental Pinned experimental releases — same version numbers but pushed from exp-v* tags. The :experimental floating tag always points at the latest of these.

To track experimental builds in Docker Compose, use the :experimental tag:

yaml
services:
  hermes-webui:
    image: ghcr.io/nesquena/hermes-webui:experimental

Note: updating between :experimental builds requires docker compose pull followed by docker compose up -d — the floating tag is updated only when a new exp-v* release is pushed. Experimental builds are not pushed on every commit to the default branch.

When update checks are enabled, a Docker-installed WebUI compares its baked release tag with the selected stable or experimental channel. Settings shows the number of newer channel releases and the matching docker pull command even though the image does not contain .git history.

Note (v0.14+): If you use docker-compose.three-container.yml, both
hermes-agent and hermes-dashboard initialise from the same image and write
to the same hermes-home volume simultaneously. This can cause overlapping lock
files and stale gateway_state.json entries. The unified pattern described in
Three-service unified setup (v0.14+) below
avoids this by running a single hermes-agent process that serves both the
gateway and the dashboard.

If something stops working, start with the single-container setup — it's the simplest path and fixes most permission/UID/path-mismatch issues by construction.

Production image security model

The production Docker image is hardened for the normal single-tenant container threat model:
Hermes WebUI assumes one operator controls the container, mounted Hermes home, and workspace.
The image does not install sudo, does not add runtime users to a sudo group, and does not
grant NOPASSWD escalation. If an agent/tool process gains a shell as hermeswebui, it should
not be able to become root with a passwordless sudo command.

The entrypoint still starts as root for a narrow init phase because Docker bind mounts often need
UID/GID alignment and ownership preparation before the app can read ~/.hermes, /workspace,
/app, and /uv_cache. After that setup, docker_init.bash re-execs itself as the unprivileged
hermeswebui user and starts the server there. Init scratch files under /tmp/hermeswebui_init
are owner-only (0700 directory, 0600 files), not world-writable.

For multi-tenant or hostile-container environments, rebuild with your own runtime user, mount policy,
and supervisor assumptions. Development images that need package-manager convenience should add
those tools in a dev-only Dockerfile instead of reintroducing passwordless sudo to production.

5-minute quickstart (single container)

bash
git clone https://github.com/nesquena/hermes-webui
cd hermes-webui
cp .env.docker.example .env
# Edit .env if needed (most users can skip this on Linux)
docker compose up -d
open http://localhost:8787

That's it for a real personal Docker install. Your existing ~/.hermes
directory is mounted, your ~/workspace is browsable, and the WebUI
auto-detects your UID/GID from the mounted volume.

The single-container setup runs the WebUI only. It can create cron jobs and run
them manually from the Tasks panel. In Docker, scheduled jobs require the Hermes gateway daemon
to tick while you are away. If System Settings shows Gateway not configured,
use docker-compose.two-container.yml,
docker-compose.three-container.yml, or run hermes gateway separately before
relying on offline scheduled runs. See Scheduled jobs and the gateway daemon below for the full background and verification steps.

For troubleshooting, reinstall, or onboarding reproduction trials, do not mount
your real ~/.hermes unless you intentionally want to test real state. Use an
isolated Hermes home and follow
docs/onboarding-agent-checklist.md instead.

Linux note: run Compose as the user who owns the Hermes home. The command
sudo docker compose up -d can make Compose expand ${HOME} as /root, so
the default ${HOME}/.hermes bind mount becomes /root/.hermes instead of
your user's real Hermes directory. Prefer adding your user to the docker group
and running docker compose up -d; if you must preserve the caller environment
for a one-off root run, use sudo -E docker compose up -d and verify the
rendered mount with docker compose config first.

Optional GPU runtime image

The default Hermes WebUI Docker image stays CPU-only. GPU user-space packages
are installed only when you build a custom image with the opt-in build arg:

bash
docker build --build-arg INSTALL_GPU_LIBS=1 -t hermes-webui:gpu .

That build path installs VA-API basics (libva2, vainfo), AMD Mesa VA-API
drivers (mesa-va-drivers), and the Intel non-free media driver when that
package is available from the configured Debian repositories. NVIDIA host
runtime tooling is not installed into the app image; use the NVIDIA Container
Toolkit on the host and pass GPUs through at runtime.

GPU passthrough still depends on host drivers, Docker runtime support, and
device mappings. The commands below are configuration guidance for a suitable
Linux Docker host; they are not a claim that native GPU passthrough was verified
in this workspace.

Intel and AMD VA-API

Expose the host render devices and add the runtime user to the common video and
render groups:

bash
docker run --rm \
  --device /dev/dri:/dev/dri \
  --group-add video \
  --group-add render \
  hermes-webui:gpu vainfo

For Compose, add the same mapping to a custom service definition:

yaml
services:
  hermes-webui:
    image: hermes-webui:gpu
    devices:
      - /dev/dri:/dev/dri
    group_add:
      - video
      - render

vainfo should list the VA-API driver and supported profiles when the host
driver stack and container permissions are correct. The container entrypoint
preserves Docker-provided supplemental groups before it drops privileges to the
hermeswebui runtime user, so the WebUI process keeps access to /dev/dri.

NVIDIA

Install and configure the NVIDIA Container Toolkit on the host first, then use
Docker's GPU runtime flag:

bash
docker run --rm --gpus all hermes-webui:gpu nvidia-smi

For Compose, use a custom service with GPU access enabled:

yaml
services:
  hermes-webui:
    image: hermes-webui:gpu
    gpus: all

If nvidia-smi is unavailable or reports no devices, fix the host NVIDIA driver
and container toolkit setup before debugging Hermes WebUI. The container image
only supplies the WebUI plus optional user-space media libraries; it cannot
provide host kernel drivers or the NVIDIA runtime.

Scheduled jobs and the gateway daemon

Symptom: Cron jobs created in the Tasks panel never fire. System Settings or Tasks shows:

  • Orange "Gateway not configured", or
  • Red "Gateway metadata stale" when runtime metadata is stale, or
  • Red "Gateway endpoint not reachable" when WebUI has a gateway URL configured but cannot reach its health endpoint.

Cause: Scheduled cron ticks are not driven by the WebUI itself. The gateway daemon ticks the scheduler every 60 seconds; without one running, scheduled jobs sit idle. "Run now" / "Trigger" buttons still work because the WebUI handles those in-process.

The cron list itself is still read from the shared HERMES_HOME volume, not
from the gateway HTTP API. If the Tasks panel shows a gateway warning while the
job list loads, the warning is about scheduled ticking / gateway health, not
about the list endpoint.

In older gateway builds, or when the daemon runs in a separate container, gateway_state.json can become stale and WebUI may lose confidence even if the daemon is up. This is especially visible if the WebUI container has no gateway URL and can only inspect local state files from its own container.

Fix: Run a gateway container alongside the WebUI. The two-container compose file is the recommended path:

bash
cp .env.docker.example .env
docker compose -f docker-compose.two-container.yml up -d

The compose files forward API_SERVER_KEY from .env into the hermes-agent
container. The agent only starts the gateway API listener (port 8642) when
API_SERVER_KEY is a usable value (>=16 chars) — API_SERVER_ENABLED alone
does nothing. Without a key, the gateway daemon still runs but port 8642 stays
unbound and the WebUI keeps showing "Gateway endpoint not reachable". To
enable scheduled ticking and the green gateway pill, set a long random string
in .env:

bash
echo "API_SERVER_KEY=$(openssl rand -hex 24)" >> .env
docker compose -f docker-compose.two-container.yml up -d --force-recreate

The compose file forwards the same value to the WebUI as
HERMES_WEBUI_GATEWAY_API_KEY, so the health probe authenticates automatically.

The three-container layout adds the dashboard but is otherwise the same shape. If you must stay single-container, you can run hermes gateway inside the container as a long-lived background process, but the compose split is sturdier.

If you maintain a custom compose file, make sure the WebUI service points at
the gateway service over the compose network:

yaml
services:
  hermes-webui:
    environment:
      - HERMES_API_URL=http://hermes-agent:8642
      # HERMES_WEBUI_GATEWAY_BASE_URL=http://hermes-agent:8642 also works.

If WebUI browser chat is routed through that gateway and you expect approval prompts for guarded tools, set the gateway chat backend and opt into the runs API path in the WebUI service:

yaml
services:
  hermes-webui:
    environment:
      - HERMES_WEBUI_CHAT_BACKEND=gateway
      - HERMES_WEBUI_GATEWAY_BASE_URL=http://hermes-agent:8642
      - HERMES_WEBUI_GATEWAY_USE_RUNS_API=true
      # HERMES_WEBUI_GATEWAY_API_KEY=... when the gateway requires API auth.

HERMES_WEBUI_GATEWAY_USE_RUNS_API=true is required for gateway approval cards because approval-capable gateway runs emit approval requests on the runs API transport. Leaving it unset keeps browser chat on the legacy chat-completions path.

Do not copy only API_SERVER_ENABLED=true / API_SERVER_HOST=0.0.0.0 into the
agent service as a standalone fix. If you intentionally enable the agent API
server, the agent also requires a real API_SERVER_KEY (at least 8 characters),
and the WebUI still needs HERMES_API_URL or HERMES_WEBUI_GATEWAY_BASE_URL to
reach that service from its container.

Verify: Once the gateway is up, the System Settings pill should turn green and the Tasks banner disappear. From the host:

bash
export GATEWAY_BASE_URL="${HERMES_API_URL:-${HERMES_WEBUI_GATEWAY_BASE_URL:-http://hermes-agent:8642}}"
docker compose -f docker-compose.two-container.yml exec hermes-agent hermes gateway status
curl -sS "${GATEWAY_BASE_URL%/}/health/detailed" | jq '.gateway_state, .state'

If the service name differs in your compose file, docker compose -f docker-compose.two-container.yml ps lists the running services.
For container-to-container diagnostics, set one of HERMES_API_URL or HERMES_WEBUI_GATEWAY_BASE_URL in the WebUI environment, then restart WebUI.

Refs #2785, #4483.

Three-service unified setup (v0.14+)

Since v0.14, hermes-agent can serve the gateway API and the built-in dashboard
from the same process by setting HERMES_DASHBOARD_HOST and
HERMES_DASHBOARD_PORT. Running agent and dashboard in one container means a
single writer to hermes-home, eliminating the concurrent-init write conflicts
that occur when hermes-agent and hermes-dashboard both start from the same
image against the same volume.

The three-service pattern uses two containers:

Service Image Ports
hermes-agent nousresearch/hermes-agent:latest 8642 (gateway), 9119 (dashboard)
hermes-webui ghcr.io/nesquena/hermes-webui:latest 8787 (chat UI)

Example compose snippet (save as docker-compose.three-service.yml or inline into your own file):

yaml
services:
  hermes-agent:
    image: nousresearch/hermes-agent:latest
    container_name: hermes-agent
    command: gateway run
    ports:
      - "127.0.0.1:8642:8642"
      - "127.0.0.1:9119:9119"
    volumes:
      - hermes-home:/home/hermes/.hermes
      - hermes-agent-src:/opt/hermes
    environment:
      - HERMES_HOME=/home/hermes/.hermes
      - HERMES_UID=${UID:-1000}
      - HERMES_GID=${GID:-1000}
      - HERMES_DASHBOARD_HOST=0.0.0.0
      - HERMES_DASHBOARD_PORT=9119
    restart: unless-stopped
    networks:
      - hermes-net

  hermes-webui:
    image: ghcr.io/nesquena/hermes-webui:latest
    container_name: hermes-webui
    depends_on:
      - hermes-agent
    ports:
      - "127.0.0.1:8787:8787"
    volumes:
      - hermes-home:/home/hermeswebui/.hermes
      - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
      - ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
    environment:
      - HERMES_WEBUI_HOST=0.0.0.0
      - HERMES_WEBUI_PORT=8787
      - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
      - WANTED_UID=${UID:-1000}
      - WANTED_GID=${GID:-1000}
    restart: unless-stopped
    networks:
      - hermes-net

networks:
  hermes-net:
    driver: bridge

volumes:
  hermes-home:
  hermes-agent-src:

Open http://localhost:8787 for chat and http://localhost:9119 for the dashboard.
Check hermes gateway run --help for the exact flag names for your agent release —
the env-var equivalents shown above (HERMES_DASHBOARD_HOST, HERMES_DASHBOARD_PORT)
are available in recent releases alongside the CLI flags.

If you need the separate dashboard container (e.g. resource limits per service),
docker-compose.three-container.yml still works. Add a depends_on from
hermes-dashboard to hermes-agent with a condition: service_healthy healthcheck
so the dashboard waits for the gateway to finish initialising agent-home before it
starts its own init pass.

What goes wrong (and how to fix it)

Compatibility policy and version pinning

WebUI shows the version it is currently running, but that display does not in itself guarantee tested compatibility with your agent release.

Until the compatibility boundary work in #1925 and #2491 land, the WebUI and Hermes Agent deployment should be treated as a release pair: the WebUI release is tested against its matching agent release and should be upgraded/pinned together.

If you use latest, use it consistently on both sides and avoid mixing a fixed tag with latest:

  • fixed WebUI tag + hermes-agent:latest
  • hermes-webui:latest + fixed hermes-agent tag

In multi-container setups, if you must run a pinned pair, prefer the matching tag in docker-compose.two-container.yml/docker-compose.three-container.yml and perform the agent-volume refresh workflow in Upgrading the agent container whenever you upgrade the agent image.

If you see behavior issues after a mixed-version upgrade, capture both WebUI and hermes-agent versions and the compose layout in the issue.

1. "Permission denied" at startup

Symptom: Container starts but immediately crashes, logs show:

text
PermissionError: [Errno 13] Permission denied: '/home/hermeswebui/.hermes/...'

Cause: The container's user (UID 1000 by default) can't read your bind-mounted directory because your host files are owned by a different UID.

Fix: Set UID and GID in .env to match your host:

bash
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
docker compose down && docker compose up -d

On macOS, host UIDs start at 501. On Linux, the first interactive user is usually UID 1000.

macOS Docker Desktop: if UID mapping still misbehaves after the env fix, try toggling Settings → General → File sharing implementation between VirtioFS and gRPC-FUSE. Different implementations preserve UIDs across the host/container boundary differently.

2. ".env file mode 0640 → permission denied" (#1389)

Symptom: You set HERMES_HOME_MODE=0640 (or some other group-readable mode) on your host .env file, container starts, then errors out:

text
[security] fixed permissions on .env (0o640 -> 0600)
failed to load .env: open .env: permission denied

Cause: WebUI's fix_credential_permissions() startup hook enforces 0600 by default. This is the right thing for a clean install but conflicts with operator-set modes.

Fix: Set one of these env vars in your .env:

  • HERMES_SKIP_CHMOD=1 — bypass the fixer entirely
  • HERMES_HOME_MODE=0640 — allow group bits, only strip world-readable

Both are documented in api/startup.py::fix_credential_permissions().

⚠️ Multi-container warning: HERMES_HOME_MODE has DIFFERENT semantics in the agent image vs. the WebUI:

  • WebUI: credential FILE mode threshold (0640 allows group bits on .env)
  • Agent: HERMES_HOME directory mode (default 0700)

0640 on a directory has no owner-execute bit, so the agent can't traverse its own home → bricked. For multi-container setups, use HERMES_HOME_MODE=0750 (group-traversable) or 0701 (x-only). The compose files have per-service comments that match each side's semantics.

3. "Workspace appears empty even though my files are there"

Symptom: WebUI loads but /workspace shows no files.

Cause: Same as #1 — UID mismatch on the bind mount.

Fix: Same as #1 — match host UID/GID via .env.

4. "Two-container setup: WebUI can't find agent source" (#858)

Symptom: WebUI logs at startup:

text
!! WARNING: hermes-agent source not found.
!!   Looked in: /home/hermeswebui/.hermes/hermes-agent
!!              /opt/hermes

Cause: The agent's source (/opt/hermes inside the agent container) needs to be exposed to the WebUI container via a shared volume. The two-container compose file does this via hermes-agent-src named volume, but if you're using bind mounts incorrectly the path won't resolve.

Fix: Use the named volumes that ship with docker-compose.two-container.yml — don't replace them with bind mounts unless you know what you're doing. The agent container writes its source to /opt/hermes, and the WebUI mounts that volume at /home/hermeswebui/.hermes/hermes-agent.

If you must use a bind mount: pick a host path, then mount it to /opt/hermes in the agent container AND /home/hermeswebui/.hermes/hermes-agent in the WebUI container.

5. "Tools (git, node, etc.) missing in two-container setup" (#681)

Symptom: You ask the agent to run git status in chat and it errors with command not found.

Cause: This is architectural, not a bug. In the two-container setup, agent processes started by the WebUI run inside the WebUI container, not the agent container. The WebUI image doesn't include git/node by design (it's a UI image, not a tool host).

Workarounds:

  • Single-container setup (docker-compose.yml) — everything in one container, no boundary
  • Custom WebUI image — extend the Dockerfile to install the tools you need
  • Combined image (sunnysktsang/hermes-suite) — community fork that ships agent+webui+dashboard in one container

6. "config.yaml not loaded"

Symptom: You have a config.yaml in your host ~/.hermes/, but the WebUI shows "no model configured" or doesn't pick up your custom providers.

Cause: Either the file isn't readable (UID/GID issue, see #1) or it's not in the expected path inside the container.

Fix:

  • Verify: docker exec hermes-webui ls -la /home/hermeswebui/.hermes/config.yaml
  • If it doesn't exist: your host bind mount is pointing at the wrong directory.
  • If it exists but is unreadable: see #1 for the UID/GID fix.

7. "On Podman: can't share .hermes between containers"

Symptom: Two-container setup works on Docker but fails on Podman with permission errors no matter what UID/GID you set.

Cause: Podman 3.4 (Ubuntu 22.04 default) has limited support for userns_mode: keep-id across multiple containers — files written by one container appear with a different UID in the other.

Fix: Either upgrade to Podman 4+ (which fixes this), or use the single-container setup, or use the community all-in-one image.

8. "API base URL set to localhost fails from Docker" (#3012)

Symptom: A provider, local model server, webhook, or custom API works on the host at http://localhost:, but fails when the same URL is configured in Hermes WebUI running in Docker.

Cause: Inside a container, localhost means that container, not your laptop/host. The WebUI process cannot reach host services through 127.0.0.1 unless the service is running inside the same container.

Fix: Point Docker-hosted WebUI at the host gateway name instead:

  • Docker Desktop on macOS/Windows: http://host.docker.internal:
  • Podman: http://host.containers.internal:
  • Linux Docker Engine: either publish the host service on the Docker bridge address, or add a host-gateway alias to your compose service:
yaml
services:
  hermes-webui:
    extra_hosts:
      - "host.docker.internal:host-gateway"

Then configure the URL as http://host.docker.internal:. Also ensure the host service binds to an address reachable from containers (not only a loopback interface the Docker bridge cannot reach) and that your host firewall allows the connection.

9. "Failed to verify state directory" / restart loop on a bind-mounted state dir (#7027)

Symptom: Single-container deploy with the state directory bind-mounted from a
host directory, no WANTED_UID set. The container exits 1 and restart-loops:

text
-- Auto-detected workspace UID: 1024 (from /workspace)
touch: cannot touch '/app/data/.testfile': Permission denied
!! ERROR: Failed to verify state directory at /app/data

Cause: UID auto-detection used to read /workspace before the configured
state directory. In a stock image /workspace exists and is owned by the
image's own build-time 1024:1024, so detection returned a value that carries
no information about the host — and because 1024 is also the fallback default,
the log read as if detection had found nothing.

Fix: Fixed in the init script — the configured HERMES_WEBUI_STATE_DIR is
now probed first. The full order is:

  1. $HERMES_WEBUI_STATE_DIR (default /app/data) — a bind mount by definition
    in a single-container deploy, so its owner is the host identity to match
  2. /home/hermeswebui/.hermes, $HERMES_HOME, /opt/data — the hermes-home
    shared volume in two-container setups (#668)
  3. /workspace — used only when nothing above resolves
  4. 1024 — fallback default

Root-owned candidates (UID 0, e.g. a freshly created named volume) are skipped
at every step. An explicitly supplied WANTED_UID/WANTED_GID always wins and
is never overwritten by detection — including the value 1024, which earlier
versions treated as "unset".

If you are on an older image, the workaround is to set the IDs explicitly:

bash
docker run -e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) ...

Multi-container architecture

The two- and three-container setups use named Docker volumes (not bind mounts) by default for a reason: named volumes solve the UID/GID problem by construction. Docker creates the volume's root directory with the correct ownership, all containers reading/writing to it see the same files, no host-side permission setup required.

text
┌─────────────────────────────────┐
                 │      hermes-home (volume)       │
                 │  (config, sessions, state, ...)  │
                 └─────────────────────────────────┘
                          ↑              ↑
                          │ rw           │ rw
                          │              │
      ┌──────────────┐    │              │    ┌──────────────┐
      │ hermes-agent │────┘              └────│ hermes-webui │
      │  (port 8642) │                        │  (port 8787) │
      └──────────────┘                        └──────────────┘
              │                                       ↑
              │ rw                                    │ ro
              ↓                                       │
      ┌─────────────────────────┐                     │
      │ hermes-agent-src (vol)  │─────────────────────┘
      │ (agent's Python source) │
      └─────────────────────────┘

The WebUI container doesn't ship with the agent's Python deps — at startup it runs uv pip install /home/hermeswebui/.hermes/hermes-agent to install them from the shared volume. The WebUI mount is read-only; the agent container is the only writer.

Upgrading the agent container

The hermes-agent-src named volume is initialised from the agent image's /opt/hermes on first up. Docker reuses the volume verbatim on every subsequent up — even after docker pull of a newer agent image. The cached volume content masks the new image's source tree, so a fresh docker pull of nousresearch/hermes-agent:latest does not by itself give you the new agent code, dependencies, or entrypoint.

This is the root cause of #1416: the symptom looked like a missing entrypoint, but the entrypoint was actually present in the new image and hidden behind the stale named volume.

To upgrade the agent image cleanly, drop the source volume before recreating:

bash
# Two-container setup
docker compose -f docker-compose.two-container.yml down
docker volume rm <project>_hermes-agent-src
docker compose -f docker-compose.two-container.yml pull
docker compose -f docker-compose.two-container.yml up -d

# Three-container setup
docker compose -f docker-compose.three-container.yml down
docker volume rm <project>_hermes-agent-src
docker compose -f docker-compose.three-container.yml pull
docker compose -f docker-compose.three-container.yml up -d

Replace `` with your Compose project name (the parent directory by default; check with docker volume ls). The hermes-home volume (config, sessions, state) is left untouched — only hermes-agent-src (the agent's installed Python source) is recreated.

The single-container setup (docker-compose.yml) does not use hermes-agent-src and is not affected by this upgrade pattern — pulling a newer WebUI image and docker compose up -d --force-recreate is sufficient.

What the multi-container setup isolates (and what it doesn't)

The two- and three-container setups give you process, network, and resource isolation between the gateway and the chat UI:

  • Each service has its own PID namespace and lifecycle — the agent process can crash without taking down the chat UI and vice versa.
  • The gateway API (port 8642) is bound by the agent service only; the WebUI cannot bind it. Other containers reach the gateway via the hermes-net Docker network.
  • Resource limits (deploy.resources.limits in docker-compose.three-container.yml) apply per service, so you can cap the agent independently of the dashboard.
  • Restart policies, log streams, and container health checks are scoped per service.

What multi-container does not isolate:

  • Filesystem boundary. Both services share hermes-home (config, sessions, state), and the WebUI mounts the agent's installed source from hermes-agent-src. The WebUI mount is read-only (since v0.51.84), but the agent service still has write access, and both services share the home volume.
  • UID/GID boundary. Both services default to ${UID:-1000} so files written by one are readable by the other. If you align them to different UIDs you'll get permission errors on the shared volume.
  • Trust boundary on the agent source. The WebUI installs Python dependencies from the shared hermes-agent-src volume at startup. The read-only mount means a compromised WebUI cannot rewrite the agent source, but it does run code from that volume.

If you need filesystem isolation between the chat UI and the agent (e.g. you don't trust the WebUI to read agent state), the multi-container setup is not enough — run the agent on a separate host and connect the WebUI to it via the gateway HTTP API. If you don't need any boundary, the single-container setup is simpler.

The direct source mount is a compatibility bridge, not the long-term API contract. The current source/API boundary inventory and decoupling task list live in docs/rfcs/agent-source-boundary.md for #2453. If you customize the compose files with bind mounts, keep the WebUI-side agent source mount read-only unless you are intentionally doing local development; docker_init.bash warns at startup when that path is writable.

Bind-mount migration (advanced)

If you really need to bind-mount an existing host ~/.hermes (e.g. you're keeping config in dotfiles, sharing with a non-Docker hermes install, etc.):

yaml
volumes:
  hermes-home:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /home/youruser/.hermes
  hermes-agent-src:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /opt/hermes-agent-source

Critical requirements:

  1. The host directory MUST be readable by your container UID. Run id -u on the host and ensure ~/.hermes is owned by that UID (or readable via group bits).
  2. ALL containers sharing the volume must run as the SAME UID/GID. Set UID=$(id -u) and GID=$(id -g) in .env.
  3. If you run Compose with sudo, do not rely on ${HOME} defaults: sudo often changes $HOME to /root, so ${HERMES_HOME:-${HOME}/.hermes} becomes /root/.hermes. Prefer running Docker as your user; otherwise pass absolute paths with sudo -E, for example HERMES_HOME=/home/youruser/.hermes HERMES_WORKSPACE=/home/youruser/workspace sudo -E docker compose up -d, and confirm the rendered bind mount with docker compose config.
  4. If your host .env is mode 0640, set HERMES_SKIP_CHMOD=1 or HERMES_HOME_MODE=0640 so the startup hook doesn't try to enforce 0600.

Reference

  • #1416 — agent-image upgrade requires removing hermes-agent-src named volume (see Upgrading the agent container)
  • #1389 — HERMES_HOME_MODE override (fixed in v0.50.254 — agent honors HERMES_SKIP_CHMOD and HERMES_HOME_MODE)
  • #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide)
  • #3012 — host localhost API URLs fail from Docker containers (use host.docker.internal / host.containers.internal)
  • #3006 — sudo docker compose can mount /root/.hermes instead of the user's Hermes home
  • #3243 — optional GPU runtime image/docs for containerized acceleration workloads
  • #858 — two-container /opt/hermes path confusion
  • #681 — tools running in WebUI container, not agent container (architectural)
  • #668 — auto-detect UID/GID from mounted volume
  • #569 — UID/GID detection priority order
  • #7027 — state dir probed before /workspace in UID/GID detection (see #9 above)

If you hit a new failure mode not covered here, please open an issue with:

  1. Which compose file you used
  2. The error from docker logs hermes-webui
  3. docker exec hermes-webui id output
  4. docker exec hermes-webui ls -la /home/hermeswebui/.hermes output

Kanban (`docs/kanban.md`)

Kanban board status flow

The Kanban panel (static/panels.js) is a view over the Hermes Agent's
hermes_cli.kanban_db; api/kanban_bridge.py handles WebUI task writes.
Structured transitions use the Agent's own verbs; the claim-aware direct
write _set_status_direct() (api/kanban_bridge.py:281) still covers triage,
todo and some moves to ready. Scheduled (#7900) is covered in detail below.

Columns

BOARD_COLUMNS (api/kanban_bridge.py:32) fixes the board order:

text
triage → todo → scheduled → ready → running → blocked → done

archived is also accepted as a status by _validate_status
(api/kanban_bridge.py:272) but is rendered as a terminal flag rather than a
column.

running is never entered from the UI: the bridge rejects
PATCH {"status": "running"} with HTTP 400 (api/kanban_bridge.py:431)
because the dispatcher/claim_task protocol owns claim_lock,
claim_expires and worker_pid.

Where status can be changed

Surface Location Statuses offered
Drag a card between columns (desktop only — HTML5 drag, no touch drag) static/panels.js any column
Detail-view status buttons (the touch path: phone/tablet) static/panels.js:3868 triage, todo, scheduled, ready, blocked, done, archived, plus Block / Unblock
Sidebar bulk status select static/index.html:237 scheduled, ready, blocked, done, archived
Task editor modal static/panels.js:3484 triage, todo, ready
Dispatcher (claim) Hermes Agent ready → running

All of those funnel into _patch_task() (api/kanban_bridge.py:380), which
switches on the target status and calls the matching Agent verb. The bulk
endpoint does the same per id (api/kanban_bridge.py:719), so a multi-select
move to Scheduled is refused exactly like a single-card move would be.

Scheduled

A Scheduled card is a time-delayed task: it is visible in its column but the
dispatcher does not claim it while it stays there.

Entering Scheduled

PATCH /api/kanban/tasks/ with {"status": "scheduled"} — what the
detail-view button, a drag onto the Scheduled column and the bulk select all
send. The scheduled branch (api/kanban_bridge.py:458) then:

  1. raises scheduling requires a newer Hermes Agent when the installed Agent
    exposes no schedule_task — HTTP 409, with no mutation, so an Agent
    that cannot schedule never ends up with a raw status write;
  2. delegates to kb.schedule_task(conn, task_id, reason=...), the Agent's own
    verb, which accepts todo / ready / running / blocked → scheduled.
    A Done → Scheduled move is refused by the Agent and surfaces here as HTTP
    400 cannot schedule task from status: done, and an active run is settled
    by the Agent with outcome="scheduled" rather than left orphaned;
  3. passes reason only when the caller actually sent one, so a plain status
    move never synthesizes a run record that never happened and never
    overwrites the task's latest worker summary.

Leaving Scheduled

  • → ready (api/kanban_bridge.py:443): when the current status is
    blocked or scheduled, the bridge calls kb.unblock_task() instead of
    a direct write. unblock_task re-gates on parent completion — a child whose
    parent is still unfinished lands in todo (not ready) and emits an
    unblocked event, while a child with all parents complete lands in ready.
  • → triage / todo: _set_status_direct() (api/kanban_bridge.py:281),
    the claim-aware direct write.
  • → done / blocked / archived: the Agent's complete_task / block_task
    / archive_task.
  • Unblock button on the detail view (api/kanban_bridge.py:768): also
    calls kb.unblock_task(), so it applies the same parent re-gating.

The task editor follows the Blocked precedent

scheduled is deliberately not in the modal's status <select>.
_kanbanEditableStatusFor() (static/panels.js:3484) maps it to triage for
display exactly like blocked / running / done / archived, so opening a
Scheduled card shows Triage together with the
Actual status: Scheduled hint (static/panels.js:3541), and an untouched
save omits status from the PATCH payload entirely
(static/panels.js:3711) — saving without touching the field keeps the card
Scheduled instead of silently demoting it to Triage.

Changing status on a Scheduled card from the modal is therefore explicit: the
payload carries status only when the user picked a value different from the
displayed default, and that value still goes through _patch_task() and the
Agent's verb for the target status.


Onboarding Agent Checklist (`docs/onboarding-agent-checklist.md`)

Agent-assisted onboarding checklist

This checklist is for an AI assistant helping a human install, reinstall, or
debug Hermes WebUI onboarding. It does not replace the human first-run wizard.
Use it before running bootstrap commands, inspecting logs, or recommending a
cleanup path.

If you are an AI assistant, read this file before assisting with onboarding,
bootstrap, provider setup, reinstall, or first-run support.

Role split

The human operator owns:

  • choosing the install path
  • choosing the provider and model
  • entering API keys, OAuth codes, and passwords
  • approving any cleanup of a real Hermes home
  • approving any external exposure outside localhost

The assistant owns:

  • using isolated trial directories unless the human explicitly says otherwise
  • checking non-secret status endpoints and logs
  • explaining which step passed or failed
  • collecting redacted evidence for Discord or GitHub support
  • stopping before destructive cleanup, credential handling, or public exposure

Hard safety rules

  • Do not delete, move, or overwrite the real ~/.hermes directory unless the
    human explicitly asks for that exact action.
  • Do not print API keys, OAuth tokens, cookies, full .env files, full
    auth.json files, or password hashes.
  • Do not modify real cron jobs, real sessions, real profiles, or real memory
    files during an onboarding trial.
  • Do not expose WebUI on a public interface without password protection and
    explicit human approval.
  • Do not proxy or tunnel local service checks such as localhost,
    127.0.0.1, private LAN addresses, or Docker container loopback paths.

Pre-flight

Confirm the basic context:

bash
pwd
git branch --show-current
git rev-parse --short HEAD
python3 --version

Check whether repo-local environment overrides will affect bootstrap:

bash
test -f .env && grep -n 'HERMES_HOME\|HERMES_WEBUI_STATE_DIR\|HERMES_WEBUI_PORT\|HERMES_WEBUI_HOST' .env

If .env exists, do not print the full file. Inspect only the specific
non-secret keys needed to understand the active Hermes home, WebUI state
directory, port, or host.

Isolated local trial

Use an isolated Hermes home and WebUI state directory for a reinstall or support
trial. This keeps the test away from the operator's real memory, sessions,
profiles, credentials, and cron state.

bash
mkdir -p ~/hermes-onboarding-test
HERMES_HOME=~/hermes-onboarding-test/.hermes \
HERMES_WEBUI_STATE_DIR=~/hermes-onboarding-test/webui \
HERMES_WEBUI_PORT=8789 \
python3 bootstrap.py

Open:

text
http://127.0.0.1:8789

The bootstrap writes a port-specific log under the selected WebUI state
directory:

text
~/hermes-onboarding-test/webui/bootstrap-8789.log

For daemon-style installs, ctl.sh writes the daemon log to the active
HERMES_HOME by default:

text
~/.hermes/webui.log

When using the isolated trial environment, prefer the bootstrap command above
unless the human specifically wants to validate ctl.sh.

Non-secret evidence commands

After the server starts, collect status without secrets:

bash
curl -sS http://127.0.0.1:8789/health
curl -sS http://127.0.0.1:8789/api/onboarding/status
find ~/hermes-onboarding-test -maxdepth 3 -type f | sort
tail -n 120 ~/hermes-onboarding-test/webui/bootstrap-8789.log

When summarizing /api/onboarding/status, focus on:

  • completed
  • system.hermes_found
  • system.imports_ok
  • system.config_path
  • system.config_exists
  • system.setup_state
  • system.provider_configured
  • system.provider_ready
  • system.chat_ready
  • system.current_provider
  • system.current_model
  • system.current_base_url
  • system.env_path

Do not paste the full payload if it contains unexpected sensitive local paths
or values. Redact paths and provider details when the human asks for a public
GitHub or Discord support report.

Pass criteria

A local onboarding trial passes when:

  • /health returns successfully.
  • /api/onboarding/status returns JSON.
  • The wizard appears when completed is false.
  • The wizard stays out of the way when completed is true or
    HERMES_WEBUI_SKIP_ONBOARDING=1 is intentionally set.
  • system.hermes_found and system.imports_ok match the expected bootstrap
    state.
  • system.provider_ready and system.chat_ready become true after the human
    completes a provider path that should support chat.
  • system.config_path and system.env_path point inside the intended isolated
    HERMES_HOME during a trial.
  • WebUI files are written under the intended HERMES_WEBUI_STATE_DIR.

If the human chooses a provider that must be completed in the CLI, passing can
mean the wizard correctly points them to hermes model or hermes auth rather
than trying to collect unsupported credentials in the browser.

Failure triage

If the server does not start:

  • check the bootstrap log
  • check for a port conflict on 8789
  • confirm Python can run bootstrap.py
  • confirm .env is not overriding the isolated directories or port

If onboarding reports agent_unavailable:

  • confirm the bootstrap found or installed Hermes Agent
  • check whether the running Python can import run_agent.AIAgent
  • use docs/troubleshooting.md, especially the AIAgent not available flow

If onboarding reports provider_incomplete:

  • confirm whether the provider is API-key based, OAuth based, or local
  • let the human enter credentials or run the CLI auth flow
  • do not ask the human to paste secrets into chat

If a local model server does not probe successfully:

  • from native macOS/Linux, use http://127.0.0.1:/v1 when the server is
    on the same host
  • from Docker Desktop, use http://host.docker.internal:/v1
  • from another LAN machine, use the server's LAN IP and /v1
  • remember that localhost inside a container is the container itself

If password or reverse-proxy behavior is confusing:

  • keep the first pass on 127.0.0.1
  • require password protection before exposing WebUI beyond localhost
  • include the reverse proxy shape in the support report without pasting tokens
    or cookies

Final support report

Use this shape when reporting results to the human, Discord, or GitHub:

text
Install path:
OS / Python:
Repo commit:
Command used:
WebUI URL:
State isolation:
Health result:
Onboarding status summary:
Files created or changed:
Log excerpt:
Pass/fail:
Next recommended action:

Redact secrets and private paths before posting publicly.


Onboarding (`docs/onboarding.md`)

First-run onboarding guide

This guide explains what happens the first time Hermes WebUI starts, which
setup path to choose, and how to recover when the wizard cannot finish.

If an AI assistant is helping with install, reinstall, bootstrap, provider
setup, or first-run support, read
docs/onboarding-agent-checklist.md before
running commands or inspecting logs.

The short version: run the bootstrap, open the WebUI, choose a provider, choose
a workspace, optionally set a password, then start a chat. If you are using a
local model server from Docker, pay special attention to the Base URL section
below.

Before you start

Hermes WebUI is only the browser interface. The actual agent runtime, memory,
skills, config, cron jobs, and provider credentials belong to Hermes Agent.

The bootstrap supports Linux, macOS, and WSL2. Native Windows is not supported
by the bootstrap yet. A community native Windows setup is being tracked in
#1952, including:

For Windows users who want the supported path today, use WSL2 and see
Windows / WSL auto-start.

Install path choices

Path Use it when Notes
Local bootstrap You run WebUI directly on Linux, macOS, or WSL2 Best for a personal server, Mac mini, VPS, or homelab host.
Docker single-container You want the simplest container setup Recommended first Docker path. WebUI runs the agent in-process.
Docker two-container You already run the agent gateway separately More isolated, but tools launched from WebUI run in the WebUI container.
Docker three-container You want agent gateway plus dashboard plus WebUI Same caveats as two-container, plus the dashboard service.
Native Windows community path You are intentionally testing unsupported native Windows Community-maintained for now, not the official bootstrap path.

If a Docker install gets confusing, start again with the single-container setup.
It avoids most UID/GID, source-volume, and tool-location surprises. See
Docker setup guide for the full container reference.

Re-running onboarding safely

Do not delete ~/.hermes just to see the wizard again. That directory can hold
your real Hermes config, credentials, memory, skills, profiles, sessions, and
cron state.

For a clean local trial, use an isolated Hermes home and WebUI state directory:

bash
mkdir -p ~/hermes-onboarding-test
HERMES_HOME=~/hermes-onboarding-test/.hermes \
HERMES_WEBUI_STATE_DIR=~/hermes-onboarding-test/webui \
HERMES_WEBUI_PORT=8789 \
python3 bootstrap.py

Then open http://127.0.0.1:8789.

For an assistant-led trial run, follow the safety rules, evidence commands, and
pass/fail criteria in
docs/onboarding-agent-checklist.md.

If your repo has a .env file, remember that the bootstrap loads it. Remove or
adjust any HERMES_HOME, HERMES_WEBUI_STATE_DIR, or HERMES_WEBUI_PORT
entries there before using the isolated command above.

For managed hosting or fully preconfigured images, set
HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the wizard.

If the port is already in use

The bootstrap checks the resolved host and port before it installs the agent,
sets up dependencies, or creates state. When another service already holds
the port, it stops there and reports the address it could not bind together
with a free alternative it verified, for example:

text
Port 8787 on 127.0.0.1 is already in use by another service. Try an available port instead: ./start.sh 8789 Or set HERMES_WEBUI_PORT=8789 in /path/to/hermes-webui/.env. The existing service was left untouched; no configuration was changed.

Nothing is reconfigured for you: the running service is untouched and .env
is not rewritten. Re-run with the suggested port as the argument
(./start.sh 8789) or put HERMES_WEBUI_PORT=8789 in .env.

A port held by a healthy WebUI is not a conflict: re-running bootstrap (or
start.sh falling back to it without curl/wget) reports the already-running
instance ready and exits 0. Only a foreground/supervisor launch, which
would be a second server on the same port, reports the duplicate start.

Only the WebUI's own /health payload counts as that running instance: any
other service, including one answering a generic {"status": "ok"}, is
reported as the conflict above. A bind address that cannot be used at all
(--host naming another machine, for example) stays a hard error, so a
bootstrap that started nothing never claims to be running.

What the wizard checks

The first screen reports the runtime state WebUI can see:

  • Hermes Agent importability: whether WebUI can import and run AIAgent.
  • Provider status: whether config.yaml and credential state are enough for a
    chat request.
  • Password status: whether WebUI password protection is enabled.
  • Config paths: the active config.yaml and .env locations for this profile.

If the agent check fails, use Troubleshooting, especially
the AIAgent not available section. If provider setup is incomplete, continue
through the wizard or run hermes model in the same machine environment that
will run WebUI.

Choosing a provider

The setup step groups providers by how much information they usually need.

Group Examples What you usually enter
Easy start OpenRouter, Anthropic, OpenAI API key and model.
Open / self-hosted Ollama, LM Studio, custom OpenAI-compatible, AIML API Base URL, model, optional API key.
Specialized Gemini, DeepSeek, Xiaomi MiMo, Z.AI / GLM, NVIDIA NIM, Mistral, xAI Provider API key and default model.

For API-key providers, the wizard writes the key to the active Hermes .env
file and writes the default model/provider to config.yaml.

For local providers, the API key field can be blank when the server is keyless.
Most LM Studio, Ollama, vLLM, llama-server, and TabbyAPI installs run this way.
Use Test connection to verify the Base URL and populate the model list
before continuing.

After reload, a saved self-hosted Base URL is shown as Configured even when
the provider has no API key. This label means that an endpoint is saved; it does
not mean the endpoint is reachable now or that it is the active runtime
provider. Use Test connection to verify reachability.

AIML API uses the existing custom OpenAI-compatible setup path, not a
first-class built-in Hermes provider id. Configure it under the
custom-provider flow with Base URL https://api.aimlapi.com/v1, then use
either the normal custom-provider API key field or a config entry that points
at AIMLAPI_API_KEY if you want the custom provider to read its key from the
environment. Create or manage keys at https://aimlapi.com/app/keys. Model
discovery comes from the live /v1/models response for that endpoint, not from
a static WebUI-maintained model list.

Advanced provider flows such as Nous Portal and GitHub Copilot are still
terminal-first. OpenAI Codex and Anthropic Claude Code OAuth can be started in
the onboarding flow when your Hermes config selects the corresponding provider.
If the wizard points you back to hermes model, use that CLI flow first, then
refresh WebUI.

Base URL rules for local model servers

For self-hosted providers, the Base URL should point to the OpenAI-compatible
API root. Common examples:

Server Typical Base URL
LM Studio on the same non-Docker host http://127.0.0.1:1234/v1
Ollama on the same non-Docker host http://127.0.0.1:11434/v1
LM Studio from Docker Desktop http://host.docker.internal:1234/v1
Ollama from Docker Desktop http://host.docker.internal:11434/v1
Local server from Linux Docker Engine http://api.local:/v1 with api.local:host-gateway in Compose extra_hosts
Local server on another LAN machine http://<lan-ip>:/v1

Inside Docker, localhost means the WebUI container itself, not your Mac,
Windows host, Linux host, or another machine on your LAN. If LM Studio or Ollama
is running outside the container, use host.docker.internal on Docker Desktop,
use the server's LAN IP address, or add a Linux Docker host alias:

yaml
services:
  hermes-webui:
    extra_hosts:
      - "api.local:host-gateway"

Then use http://api.local:/v1 as the Base URL. The alias avoids writing
localhost in WebUI config where it would resolve to the container loopback
instead of the host service.

The wizard probes /models before saving. A successful probe fills
the model dropdown. A failed probe blocks the setup step and shows an inline
error such as DNS failure, connection refused, timeout, HTTP error, or
unexpected response shape.

Workspace step

The workspace is the filesystem location Hermes should use for new sessions.
It can be a source checkout, a project directory, or a general workspace folder.

In Docker, the default browsable path is /workspace, which maps to the host
directory mounted by the compose file. If the workspace appears empty, check the
Docker UID/GID and mount guidance in Docker setup guide.

Password step

Password protection is optional for localhost-only installs. Enable it if you
expose WebUI outside 127.0.0.1, behind a reverse proxy, or on a LAN.

For installed PWAs, prefer WebUI's built-in password over proxy basic auth. Reverse proxies are supported, but HTTP basic-auth challenges in front of the WebUI origin can interrupt the service-worker and shell-asset fetches the installed app relies on during updates. If you keep proxy auth, scope it so same-origin sw.js, manifest, and shell update requests can complete.

The password is stored through the normal WebUI settings path and hashed
server-side. You can change it later from Settings.

What gets written

The wizard uses the same files and APIs as the normal app:

  • Active Hermes config.yaml: provider, default model, and Base URL when
    relevant. A self-hosted provider keeps its saved Base URL independently of
    the active model; activating a selection updates the existing active
    provider/default-model/Base-URL tuple.
  • Active Hermes .env: provider API keys when you entered one.
  • WebUI settings.json: onboarding completion, workspace, password state, and
    other WebUI preferences.

State normally lives outside the repository. By default:

  • Hermes Agent state: Windows %LOCALAPPDATA%\hermes; POSIX ~/.hermes
  • WebUI state: $HERMES_HOME/webui (Windows default %LOCALAPPDATA%\hermes\webui, POSIX default ~/.hermes/webui)

Override these with HERMES_HOME and HERMES_WEBUI_STATE_DIR when you need an
isolated test install.

When to file an issue

File an issue when the diagnostics point to WebUI rather than local
configuration. Include:

  1. Install path: local bootstrap, Docker single-container, Docker
    two-container, Docker three-container, WSL2, or community native Windows.
  2. Output from /health, or the startup banner if the server never starts.
  3. The provider selected in onboarding and the Base URL shape, with secrets
    redacted.
  4. For Docker provider problems, the result of probing from inside the
    container, for example:
bash
docker exec hermes-webui sh -c 'curl -sS -w "\nHTTP %{http_code}\n" http://host.docker.internal:1234/v1/models | head -50'
  1. Any inline wizard error text and relevant logs.

Never paste API keys, OAuth tokens, or full .env contents into an issue.


Remote Access Zh (`docs/remote-access-zh.md`)

远程访问(中文指南)

本文是 docs/remote-access.md 的中文姊妹篇,面向中文用户,
重点覆盖 Windows 原生环境。命令与安全约定以官方脚本(start.sh / start.ps1 /
scripts/windows/setup_webui_autostart.ps1)为准。

Hermes WebUI 默认只绑定 127.0.0.1(仅本机回环),这是刻意的安全默认值。
要从手机或另一台电脑访问,需要显式选择一条安全通道。本文按推荐程度排序:

方式 安全性 复杂度 适用场景
Tailscale Serve(HTTPS + MagicDNS) ★★★★★ 低 推荐首选,手机日常使用
SSH 隧道 ★★★★★ 低 Linux/macOS 单次使用
Tailscale 直连 IP ★★★★☆ 低 Tailscale Serve 不可用时
公网/LAN 直接暴露 ★★☆☆☆ 高 不推荐,见文末安全边界

一、SSH 隧道(Linux / macOS / VPS)

服务器保持默认回环绑定不变,从本地机器开一条 SSH 隧道:

bash
ssh -N -L <本地端口>:127.0.0.1:<远程端口> <用户>@<服务器地址>

示例(远程 8787 → 本地 8787):

bash
ssh -N -L 8787:127.0.0.1:8787 [email protected]

然后浏览器打开 http://localhost:8787。

start.sh 检测到你在 SSH 会话中运行时会自动打印这条命令。


二、Tailscale 私有网络访问(推荐)

Tailscale 是基于 WireGuard 的零配置组网工具:
服务器和手机装上后自动进入同一个私有网络,无需端口转发、无需公网暴露。
Hermes WebUI 自带移动端适配布局(汉堡侧栏、抽屉式顶部标签、触控友好的控件),
作为手机的日常 AI 助手界面非常合适。

方案 A:Tailscale Serve(首选)

WebUI 保持绑定回环,只把端口"发布"到你的 tailnet:

  1. 在服务器和手机上都安装 Tailscale 并登录同一账号。

  2. 启用密码认证后启动 WebUI(保持回环绑定):

    bash
    HERMES_WEBUI_PASSWORD=你的密码 ./start.sh

    Windows 原生环境见下文"三、Windows 原生部署"。

  3. 通过 Tailscale Serve 发布本地端口:

    bash
    tailscale serve --bg 8787
  4. 用 Tailscale 打印的 HTTPS MagicDNS 地址在手机浏览器打开。

要点:

  • Tailscale Serve 让 WebUI 继续待在回环上,同时为你的 tailnet 提供 HTTPS
    MagicDNS 域名,不需要改 HERMES_WEBUI_HOST,也不需要开防火墙端口。

  • Linux 上修改 Serve 配置可能需要提权。若报 Access denied: serve config denied,
    用 sudo 执行:

    bash
    sudo -S -p '' tailscale serve --bg 8787

    或者把运行 WebUI 的非 root 用户设为 Tailscale operator:

    bash
    sudo -S -p '' tailscale set --operator=$USER
    tailscale serve --bg 8787

方案 B:直连 Tailscale IP(备选)

Serve 不可用/被禁用时,直接走 tailnet IP。因为这会超出回环绑定,必须启用密码认证:

bash
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=你的密码 ./start.sh

然后在手机浏览器打开 http://<服务器Tailscale-IP>:8787(服务器上执行
tailscale ip -4 查看自己的 Tailscale IP)。

流量全程由 WireGuard 端到端加密,密码认证在应用层保护界面。
可以把它添加到手机主屏,获得近似原生 App 的体验。


三、Windows 原生部署

Windows 用户不要直接 python server.py——请用仓库自带的原生启动器
start.ps1,它会自动完成:加载 .env、发现 Python、
定位 hermes-agent 安装目录、设置默认环境变量,然后拉起 server.py。

1. 首次准备

原生 Windows 路径要求 Python 3.11+ 与 hermes-agent 已安装。
关键:依赖要装在 start.ps1 实际使用的那个解释器里。 start.ps1
启动时会优先用 <hermes-agent>\venv\Scripts\python.exe,找不到才回退
到 PATH 上的 python——WebUI 仓库本地的 venv\ 永远不会被它挑中。
所以把 requirements.txt 装到 hermes-agent 的 venv,而不是在 WebUI
仓库下新建一个 venv。

hermes-agent 的常见安装位置(任选其一,取决于当初的安装方式):

text
%USERPROFILE%\.hermes\hermes-agent            # 官方安装器默认
%LOCALAPPDATA%\hermes\hermes-agent            # 备选
%ProgramW6432%\hermes\hermes-agent           # 全局安装

在该目录下创建 venv 并装依赖(下面以默认的 %USERPROFILE%\.hermes\hermes-agent
为例):

powershell
cd $env:USERPROFILE\.hermes\hermes-agent
python -m venv venv
.\venv\Scripts\pip.exe install -r C:\path\to\hermes-webui\requirements.txt

如果 hermes-agent 装在其它位置,把第一行 cd 换成对应目录即可。

备选:在 WebUI 仓库本地建 venv 也可以(python -m venv venv +
venv\Scripts\pip install -r requirements.txt),但这只在
<hermes-agent>\venv\Scripts\python.exe 不存在、且 PATH 上的
python.exe 恰好指向你装依赖时用的那个解释器时,start.ps1 才
会用上你装的依赖——非常容易踩坑。最稳的路径仍然是装到 hermes-agent
的 venv。

如果机器上同时存在多个 hermes-agent(例如仓库旁边还有一个源码检出),
start.ps1 只会在少数“被挤掉的布局”里改选已安装的 Agent:裸源码检出、仓库旁
sibling、仓库父目录上的 Agent 根(源码或 pip 形态),或 %USERPROFILE%\hermes-agent,且该选中项没
有自己的 venv\Scripts\python.exe。布局回退场景下,即使安装项没有 venv(依赖
已可由当前解释器导入)也可能改选;其它情况仍要求安装项带 venv。源码检出自带
venv 时仍然优先,符合 api/config.py 的源码优先顺序。想完全指定位置就设
HERMES_WEBUI_AGENT_DIR。

2. 带密码启动(前台)

powershell
$env:HERMES_WEBUI_PASSWORD = "你的密码"
.\start.ps1

默认绑定 127.0.0.1:8787。常用参数:

参数 / 环境变量 作用 示例
-Port 端口覆盖(优先于环境变量) .\start.ps1 -Port 9000
-BindHost 绑定地址覆盖 .\start.ps1 -BindHost 0.0.0.0
HERMES_WEBUI_HOST 绑定地址(默认 127.0.0.1) $env:HERMES_WEBUI_HOST = "0.0.0.0"
HERMES_WEBUI_PORT 端口(默认 8787) $env:HERMES_WEBUI_PORT = "8787"
HERMES_WEBUI_PASSWORD 界面密码认证 见上
HERMES_WEBUI_AGENT_DIR 显式指定 hermes-agent 目录 $env:HERMES_WEBUI_AGENT_DIR = "C:\path\to\hermes-agent"

绑定 0.0.0.0 前先设密码:一旦绑定所有接口,任何能路由到该端口的设备
都能触达界面,密码认证是唯一防线。参见文末"安全边界"。

改完密码先验证一下:start.ps1 会加载仓库根目录的 .env,但
已经设置的环境变量优先——上面用 $env:HERMES_WEBUI_PASSWORD
设的值会生效,.env 里的同名项被跳过。Linux / WSL 下的 start.sh
恰好相反:它会先 source 一遍 .env,.env 里的值会覆盖命令行
内联的 HERMES_WEBUI_PASSWORD=。无论用哪个启动脚本,改完密码后都用
浏览器打开登录页,确认真的要求输入密码且新密码能登录;不对的话,
先检查仓库根目录的 .env 里是否还留着旧值。

3. Windows 防火墙

用 Tailscale Serve(方案 A)时不需要任何防火墙放行——连接发生在
Tailscale 虚拟网卡与回环之间,Windows 防火墙不会拦截回环流量。

用"直连 Tailscale IP(方案 B)"时,Windows 防火墙可能会拦下从手机进来的
8787 连接。此时请只对 Tailscale 接口/网络放行,而不是对所有网络放行:

powershell
# 允许 Tailscale 网段访问 8787(按你的实际 tailnet CIDR 调整)
New-NetFirewallRule -DisplayName "Hermes WebUI (Tailscale only)" `
  -Direction Inbound -Protocol TCP -LocalPort 8787 `
  -RemoteAddress 100.64.0.0/10 -Action Allow

要点:

  • 100.64.0.0/10 是 CGNAT 保留段,Tailscale 的虚拟 IP 都落在这个范围;
    这样规则只对 tailnet 生效,公网接口依然拒绝。
  • 不要写 -Profile Any -RemoteAddress Any 这类全放行规则——那是把服务
    直接暴露到公网的行为,见"安全边界"。

四、WSL 用户:Windows 开机自启

本节只适用于在 WSL2 内运行 WebUI 的用户。 原生 Windows 启动请看
上一节"三、Windows 原生部署"——start.ps1 本身不自带开机自启
(Windows 登录时不会自动拉起 PowerShell 脚本),需要自启的话请改
用 WSL 路径或自行配置 NSSM / Task Scheduler 启动 start.ps1。

官方 WSL 自启通道是 Windows 任务计划程序 + WSL 启动脚本,配套脚本在
scripts/windows/setup_webui_autostart.ps1。
它注册一个"登录时启动"的计划任务,通过 wsl.exe 在 WSL 发行版内执行启动脚本。
该脚本幂等:重复运行只更新已有任务,不会创建重复项。

前置:WSL2 内已能手动启动 WebUI(见 docs/wsl-autostart.md),
并确保启动脚本可执行:

bash
chmod +x /path/to/hermes-webui/scripts/wsl/hermes_webui_autostart.sh

然后回到 Windows PowerShell 注册计划任务(-WslScriptPath 是 WSL 内部路径):

powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\scripts\windows\setup_webui_autostart.ps1 `
  -WslScriptPath "/home/你的用户名/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" `
  -Distro "Ubuntu"

参数说明:

  • -Distro 可省略,省略时使用默认 WSL 发行版。
  • 任务默认名 HermesWebUIAutoStart,需要改名传 -TaskName。
  • 加 -RunNow 注册后立即启动;加 -WhatIf 只预览不注册;
    仅当 WSL 路径尚不存在时才需要 -SkipValidation。

事后查看或移除任务:

powershell
Get-ScheduledTask -TaskName HermesWebUIAutoStart
Unregister-ScheduledTask -TaskName HermesWebUIAutoStart -Confirm:$false

自启日志位置(WSL 内):

text
$HOME/.hermes/webui/logs/webui_autostart.log
$HOME/.hermes/webui/logs/hermes_webui.log

五、常见问题

症状 可能原因 处理
手机打不开地址 WebUI 没绑定到可达接口 方案 A 检查 Serve 状态(tailscale serve status);方案 B 确认 HERMES_WEBUI_HOST=0.0.0.0 且防火墙已放行 Tailscale
任务计划已建但 WebUI 没起来 WSL 脚本路径写错/发行版不对 用正确的 -WslScriptPath 与 -Distro 重跑注册脚本
打开 WSL 才启动,登录时不启动 用的是会话级自启而非计划任务 按"四、WSL 用户:Windows 开机自启"装任务计划程序
健康检查失败但进程存在 端口不一致或仍在启动 核对 HERMES_WEBUI_PORT 与 hermes_webui.log
提示需要密码但没设过 界面要求认证 设置 HERMES_WEBUI_PASSWORD 后重启

六、安全边界(重要)

区分三种暴露范围:

  1. Tailscale 私有(推荐):仅你的 tailnet 设备可达。Serve 方案连端口都不用开;
    直连 IP 方案("二、方案 B")则要看 HERMES_WEBUI_HOST 设的是什么:

    • 首选: HERMES_WEBUI_HOST=$(tailscale ip -4)——只绑 Tailscale
      虚拟网卡,其他接口根本不开 8787,连防火墙关了也安全。
    • 次选: HERMES_WEBUI_HOST=0.0.0.0 + Windows 防火墙只对
      100.64.0.0/10 放行
      ("三、3 防火墙")。"仅 tailnet 可达"
      这条结论完全靠这条防火墙规则兜底——一旦放行规则被改回
      Any、或防火墙被关闭,WebUI 立刻对 LAN/公网开放
      ,因为
      0.0.0.0 是所有接口,不是只绑 Tailscale 网卡。

    WireGuard 端到端加密 + 应用层密码认证是双层保险。

  2. 局域网(LAN):HERMES_WEBUI_HOST=0.0.0.0 且防火墙对私有网段放行。
    同一 WiFi 下的其他设备都能触达——密码认证是必需品,且不建议用于公共 WiFi。

  3. 公网:端口对互联网开放。不推荐。WebUI 本身只做简单的登录限速(每个 IP
    每 60 秒最多 5 次失败尝试),但直连时没有传输加密(HTTP 明文);若确有需要,
    应放在 HTTPS 反向代理(Caddy/Nginx)之后并启用强密码。

所有超出回环的绑定,第一守则:先设 HERMES_WEBUI_PASSWORD,再改绑定地址。


原文:docs/remote-access.md · 部署相关:docs/docker.md · Windows/WSL 自启:docs/wsl-autostart.md


Remote Access (`docs/remote-access.md`)

Remote access

How to reach a self-hosted Hermes WebUI from another machine or your phone.

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:

bash
ssh -N -L <local-port>:127.0.0.1:<remote-port> <user>@<server-host>

Example:

bash
ssh -N -L 8787:127.0.0.1:8787 [email protected]

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.

Preferred setup: Tailscale Serve

  1. Install Tailscale on your server and
    your iPhone/Android.
  2. Keep the WebUI bound to localhost and enable password auth:
bash
HERMES_WEBUI_PASSWORD=your-secret ./start.sh
  1. Publish the local WebUI port through Tailscale Serve:
bash
tailscale serve --bg 8787
  1. Open the HTTPS MagicDNS URL that Tailscale prints in your phone's browser.

Tailscale Serve keeps WebUI on loopback while giving your tailnet an HTTPS
MagicDNS hostname. On Linux, changing Serve configuration may require elevated
permissions. If tailscale serve --bg 8787 reports Access denied: serve config denied, either run it with sudo:

bash
sudo -S -p '' tailscale serve --bg 8787

Or allow the supervised non-root WebUI/Hermes user to manage Tailscale:

bash
sudo -S -p '' tailscale set --operator=$USER
tailscale serve --bg 8787

Fallback: direct tailnet IP

Use direct tailnet access when Tailscale Serve is unavailable, disabled, or not
permitted. Because this binds WebUI beyond loopback, always enable password
auth:

bash
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh

Then open http://<server-tailscale-ip>:8787 in your phone's browser (find
your server's Tailscale IP in the Tailscale app or with tailscale ip -4 on
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.

Community field report: ARM64 Android via AVF

A community report in #2364
documents Hermes Agent + WebUI running on a mid-range ARM64 Android phone inside
a Debian 12 VM via Android Virtualization Framework (AVF). The reported setup
used a Xiaomi Redmi Note 13 Pro 4G, 3.8 GiB RAM allocated to the VM, 8 visible
CPU cores, Chrome on Android at localhost:8787, and cloud-hosted inference.

This is not an official support baseline or provider/model benchmark, but it is
a useful compatibility signal for mobile ARM64 experiments: the WebUI rendered
smoothly in Chrome, ARM64 Debian worked for the agent stack, and the total local
footprint was about 1.7 GB. Practical caveats from the report: first install can
take longer when dependencies compile from source, Android browser tabs may
reload when switching apps, and disabling battery optimization for the terminal
or VM host may be needed for longer-running sessions.

Tip: If using Docker, set HERMES_WEBUI_HOST=0.0.0.0 in your
docker-compose.yml environment (already the default) and set
HERMES_WEBUI_PASSWORD.



Remote Workspaces (`docs/remote-workspaces.md`)

Remote Terminal Workspaces

Architecture contract and path-resolution semantics for remote terminal profiles (SSH, Docker) in Hermes WebUI.


1. Overview

When a Hermes profile is configured with a remote terminal backend (e.g. terminal.backend: "ssh" or "docker"), its working directory (terminal.cwd) lives on the remote target host rather than the local WebUI host filesystem.

On hosts such as macOS, local path resolution via Path.resolve() or os.path.realpath() expands synthetic firmlinks (e.g. rewriting /home/ to /System/Volumes/Data/home/). Because the target-side path does not exist on the local macOS server, unconstrained local resolution causes runtime validation failures and session corruption.


2. Path Resolution Contract

  1. Target-side POSIX Path Preservation:

    • Any valid POSIX path at or beneath a remote profile's configured terminal.cwd is preserved verbatim as a Path object without calling host-local Path.resolve().
    • Traversal escapes (..), null bytes (\0), and blocked system roots (/etc, /usr, /var, /sys, /proc, /dev) continue to be strictly rejected.
  2. Profile Isolation & Boundary Enforcement:

    • Remote path recognition is strictly scoped to the target/active profile.
    • If an active profile has a local backend, target-side remote paths belonging to inactive named profiles are not treated as remote for the active local profile.
    • For local profiles, workspace addition (/api/workspaces/add) enforces host-local directory existence and permissions.
    • For remote profiles, workspace addition validates against the profile's remote terminal.cwd and skips host-local directory creation (mkdir).
  3. Session & Streaming Lifecycle:

    • Session.__init__ and Session.load normalize session workspace paths through _resolve_path(..., profile=profile), preserving target-side paths for remote profiles and preventing corruption of session.workspace or session.created_workspace.
    • Streaming execution (_run_agent_streaming) and multimodal asset root resolution preserve the remote session workspace when updating runtime run state.

Sse Streams (`docs/sse-streams.md`)

SSE streams and capability signaling

Cross-client reference for the server-sent events (SSE) endpoints Hermes WebUI
exposes. Browser and non-browser clients (Android wrapper, CLI observers)
should integrate against this page so every client describes the same
behavior.

All endpoints below are served by the WebUI origin and sit behind the same
authentication as every other /api/* route: when a WebUI password or OIDC
is configured, clients must authenticate before opening any stream.

Endpoint inventory

Endpoint Availability Purpose
GET /api/chat/stream?stream_id= Always on Live agent-turn relay (tokens, tool calls, approvals, done, stream_end). Falls back to run-journal replay when the in-memory stream is gone. Resume cursors: after_event_id / after_seq query params, with the standard Last-Event-ID header as fallback (events carry id: <stream_id>:<seq>). An invalid/foreign/ahead-of-stream cursor is honored as replay-from-start rather than silently skipping events.
GET /api/session/stream?session_id= Always on Persistent per-session channel that survives across agent turns (initial, server_turn_started, session-updated, bg_task_complete). This is the stream the WebUI frontend keeps open per session and the stream non-browser clients should prefer for background session updates.
GET /api/sessions/events Always on Global session-list invalidation (sessions_changed + keepalives). A signal to re-read /api/sessions, not a per-session lifecycle feed.
GET /api/sessions/{session_id}/events Always on Per-session run-journal relay with Last-Event-ID / after_event_id resume and snapshot fallback. See docs/rfcs/session-sse-contract-v1.md for the contract and its proof gates.
GET /api/sessions/gateway/stream Optional Real-time updates for CLI/TUI/messaging (agent) sessions merged into the sidebar. Only streams when the Agent sessions setting (show_cli_sessions) is enabled and the gateway watcher thread is running.

The authoritative event: names on /api/chat/stream are listed in the
Authoritative emitted events table of
docs/rfcs/session-sse-contract-v1.md.

Gateway probe scope (important for non-browser clients)

GET /api/sessions/gateway/stream?probe=1 returns a JSON capability payload
for the optional gateway stream only instead of holding an SSE connection:

json
{
  "enabled": false,
  "ok": false,
  "watcher_running": false,
  "fallback_poll_ms": 30000,
  "error": "agent sessions not enabled",
  "scope": "gateway_sessions",
  "session_stream_available": true,
  "session_stream_path": "/api/session/stream"
}
  • 404 + error: "agent sessions not enabled" means only that the optional
    gateway/agent-sessions stream is disabled on this server.
  • 503 + error: "watcher not started" means the setting is on but the
    gateway watcher thread is not running.
  • 200 with ok: true means gateway SSE is usable.

A negative gateway probe result must not be treated as "SSE unavailable".
The persistent per-session stream (/api/session/stream) and the chat-turn
relay (/api/chat/stream) are always on and are not gated by the Agent
sessions setting. The scope, session_stream_available, and
session_stream_path fields make this explicit so clients do not need to
infer it from the status code. Clients that only need session updates should
use /api/session/stream directly; the gateway probe is only relevant for
clients that display CLI/TUI/messaging sessions.

Gateway watcher projection lifecycle

The optional gateway stream's watcher owns a cheap database fingerprint and a
timestamp for its last full session projection. While clients are subscribed,
an unchanged fingerprint can skip the expensive read until the bounded parity
interval. With no subscribers the poll loop parks instead of reading the DB.
When the final subscriber leaves (including eviction of a full or failed
consumer queue), the watcher invalidates both cache fields. A reconnect must
therefore trigger a fresh projection even when the database fingerprint has not
changed and the parity interval has not elapsed. Final removal also advances a
subscriber epoch: an older in-flight DB read cannot restore the invalidated
cache or publish its result to a later subscriber cohort. The subscriber lock
protects removal, epoch checks and cache commits, but is not held during DB
reads, so disconnect is not blocked by a slow projection.

Reasoning settlement

When a /api/chat/stream turn settles, _settle_turn_reasoning() in
api/streaming.py persists a reasoning trace on each of the turn's new
assistant messages before the session is saved. Assistant messages from
earlier turns are never modified.

  • Non-empty agent reasoning wins. The agent writes the key on every
    assistant message it builds, but None only means the provider did not
    return final reasoning. It is not evidence the step had no thinking, so
    stream-only providers fall through to the stream.
  • Stream segments are bound to the step that owns them. When a tool
    starts, on_tool_start() binds its tool_call_id to the segment streamed
    since the previous tool, or to None if that step streamed no thinking.
    Each start is bound separately: an id repeated across steps (agents derive
    ids deterministically when providers omit them) keeps one binding per start,
    and settlement consumes them in step order.
    A visible interim message (on_interim_assistant()) also closes its step:
    the open segment is bound to that message's text, so the next tool call
    cannot claim it. Settlement resolves a tool-call step through its call ids:
    from tool_calls, else from the following tool results' tool_call_id,
    else from the next unclaimed live tool starts in order. A step with
    commentary can also resolve through its interim binding. Other
    steps are matched to interim bindings by their content, in order. The final
    step takes the segment still open at settlement. The positional index is
    used only when there are no bindings, as with older agents that lack
    tool_start_callback. It is not authoritative on its own: it advances
    only at a tool boundary that already holds reasoning, so with
    adaptive-thinking models segment k can hold step k+1's trace.
  • Inline `` blocks in the content are always split into reasoning
    and merged with the value chosen above.

Regression coverage: tests/test_settlement_agent_reasoning_authoritative.py.

Heartbeats and proxy behavior

  • All long-lived streams emit SSE keepalive comment lines on the
    _SSE_HEARTBEAT_INTERVAL_SECONDS cadence (currently 5 seconds), which is
    short enough to survive typical reverse-proxy idle timeouts.
  • Handlers send X-Accel-Buffering: no so nginx-style proxies pass events
    through unbuffered.
  • Deployments behind buffering proxies that read-until-close (notably
    Tornado-based jupyter-server-proxy) can set HERMES_WEBUI_SSE_CHUNKED=1
    to frame each event as an HTTP/1.1 chunk. The default wire format is
    unchanged when the flag is unset.

Supervisor (`docs/supervisor.md`)

Running Hermes Web UI under a process supervisor

Use a process supervisor (launchd, systemd, supervisord, runit, s6) when you
want the Web UI to start at boot, restart on crash, or be managed alongside
other services.

TL;DR

Pass --foreground to bootstrap.py (or bash start.sh):

bash
bash start.sh --foreground

Or set HERMES_WEBUI_FOREGROUND=1 in the environment. The Web UI will
auto-detect launchd / systemd / supervisord even without the flag, but being
explicit is safer.

Important (launchd on macOS): if the com.parantoux.hermes-webui LaunchAgent is enabled, treat launchd as the single source of truth for WebUI lifecycle. Do not also run ./ctl.sh start, bash start.sh, python bootstrap.py, or python server.py against the same state dir/port, or you can create a second WebUI instance and trigger port-8787 restart churn.

Why ``--foreground`` matters

Without it, bootstrap.py does this:

  1. Spawn server.py as a detached subprocess (start_new_session=True)
  2. Probe /health until the server is up
  3. Exit 0

That works for an interactive shell run (./start.sh returns to your
prompt with the server alive in the background). It is broken under any
process supervisor: the supervisor sees its tracked PID exit, marks the job
as completed, and respawns bootstrap.py. The respawn fails to bind port
8787 (the orphaned server still has it), exits non-zero, supervisor
respawns again — loop.

In foreground mode, bootstrap.py does its setup work and then calls
os.execv to replace its own process with server.py. The supervisor
sees the long-lived server as the original child. KeepAlive=true /
Restart=always work correctly.

launchd (macOS)

~/Library/LaunchAgents/com.example.hermes-webui.plist:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.example.hermes-webui</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/yourname/hermes-webui/start.sh</string>
        <string>--foreground</string>
    </array>

    <key>WorkingDirectory</key>
    <string>/Users/yourname/hermes-webui</string>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/Users/yourname/.hermes/webui/launchd-stdout.log</string>

    <key>StandardErrorPath</key>
    <string>/Users/yourname/.hermes/webui/launchd-stderr.log</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>HOME</key>
        <string>/Users/yourname</string>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Load:

bash
launchctl load ~/Library/LaunchAgents/com.example.hermes-webui.plist
launchctl print gui/$(id -u)/com.example.hermes-webui   # check state

Reload after editing the plist:

bash
launchctl unload ~/Library/LaunchAgents/com.example.hermes-webui.plist
launchctl load   ~/Library/LaunchAgents/com.example.hermes-webui.plist

launchd sets XPC_SERVICE_NAME automatically, so even without the
--foreground argument the Web UI will auto-promote to foreground mode.
The flag is still recommended as documentation of intent.

systemd (Linux)

~/.config/systemd/user/hermes-webui.service:

ini
[Unit]
Description=Hermes Web UI
After=network.target

[Service]
Type=simple
WorkingDirectory=%h/hermes-webui
ExecStart=/bin/bash %h/hermes-webui/start.sh --foreground
Restart=on-failure
RestartSec=5

# Optional: route stdout/stderr to journald instead of files
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target

Enable + start:

bash
systemctl --user daemon-reload
systemctl --user enable --now hermes-webui.service
journalctl --user -u hermes-webui.service -f

systemd sets INVOCATION_ID and JOURNAL_STREAM (when stdio is wired to
the journal), both of which auto-promote to foreground mode.

supervisord (cross-platform)

/etc/supervisor/conf.d/hermes-webui.conf:

ini
[program:hermes-webui]
command=/bin/bash /home/youruser/hermes-webui/start.sh --foreground
directory=/home/youruser/hermes-webui
user=youruser
autostart=true
autorestart=true
stopsignal=TERM
stopwaitsecs=10
stdout_logfile=/var/log/hermes-webui.out.log
stderr_logfile=/var/log/hermes-webui.err.log
environment=HOME="/home/youruser",PATH="/usr/local/bin:/usr/bin:/bin"

Reload + start:

bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status hermes-webui

supervisord sets SUPERVISOR_ENABLED, which auto-promotes to foreground
mode.

Auto-detected env vars (full list)

These trigger --foreground behavior even when the flag is not passed:

Env var Set by Notes
INVOCATION_ID systemd Set on every service activation
JOURNAL_STREAM systemd Set when stdio is wired to journald
NOTIFY_SOCKET systemd Type=notify / s6 sd_notify-style notification socket
XPC_SERVICE_NAME launchd Set to the plist Label — narrowed to com.<rdns>.<svc> form (see below)
SUPERVISOR_ENABLED supervisord Always set under supervisord
HERMES_WEBUI_FOREGROUND you Explicit opt-in; accepts 1 / true / yes / on

XPC_SERVICE_NAME noise filter

macOS launchd sets XPC_SERVICE_NAME in every Terminal-spawned shell,
not just real services. Typical noise values:

  • 0 — set on launchd descendants generally
  • application.com.apple.Terminal. — Terminal.app shells
  • application.com.googlecode.iterm2 — iTerm2
  • application.com.microsoft.VSCode — VSCode integrated terminal

A bare existence check on this var would auto-promote interactive
./start.sh runs to foreground mode on every Mac dev machine, breaking
the most common installation path. We narrow detection to launchd
Label-style names (typically reverse-DNS like com.example.foo).
Real launchd plists always use this form. If you ever see
XPC_SERVICE_NAME=0 in your service environment, the auto-detect will
ignore it — set HERMES_WEBUI_FOREGROUND=1 or pass --foreground
explicitly to be safe.

Supervisors that are NOT auto-detected

The following set no env var that we can reliably detect. Pass
--foreground (or HERMES_WEBUI_FOREGROUND=1) explicitly:

  • runit (without sd_notify) — pure runit chains
  • daemontools / svc
  • PM2 (Node.js process manager occasionally repurposed for Python)
  • Foreman / Honcho (Procfile-style)
  • Docker with a custom CMD entrypoint that doesn't already use exec
  • Custom shell-script supervisors that fork-and-wait

If your supervisor isn't in the auto-detect list and you see the orphan-PID
respawn loop, set HERMES_WEBUI_FOREGROUND=1 in the service environment.

Diagnostic recipe

If the Web UI keeps getting respawned and you suspect the double-fork loop:

bash
# Check the running PID for the server
lsof -iTCP:8787 -sTCP:LISTEN

# Get its parent — should be the supervisor itself, NOT init (PID 1)
PID=$(lsof -tiTCP:8787 -sTCP:LISTEN)
ps -p "$PID" -o pid,ppid,cmd
ps -p "$(ps -o ppid= -p "$PID" | tr -d ' ')" -o pid,cmd

A healthy foreground-mode setup looks like:

text
PID    PPID  CMD
12345  6789  /path/to/python /path/to/server.py
6789   1     /sbin/launchd        # or /usr/lib/systemd/systemd, etc.

If PPID is 1 (init) when it should be the supervisor, the orphan-server
loop is happening — re-check that --foreground (or one of the env vars)
is reaching the process.

HTTP watchdog / deep health

KeepAlive / Restart=always only recover a process that exits. If the
process is still listening on the port but request handling is wedged, pair your
supervisor with an HTTP probe and force a restart when the probe fails.

Hermes Web UI exposes two health levels:

  • /health — cheap liveness probe with active_streams, uptime, and an
    accept_loop heartbeat counter.
  • /health?deep=1 — readiness probe that briefly acquires the stream lock,
    reads the sidebar/session path, reads projects state, and touches Hermes
    state.db if it exists. Use this for watchdogs.

At startup the server also tries to raise its file-descriptor soft limit to
4096 on platforms that support RLIMIT_NOFILE. That is defense in depth for
persistent hosts: leaks should still be fixed, but a higher soft limit gives
you more diagnostic headroom before request handling falls over.

Minimal macOS launchd watchdog script:

bash
#!/usr/bin/env bash
set -euo pipefail
LABEL="com.example.hermes-webui"
BASE="http://127.0.0.1:8787"

if ! curl -fsS --max-time 10 "$BASE/health?deep=1" >/dev/null; then
  launchctl kickstart -k "gui/$(id -u)/$LABEL"
fi

Run it every few minutes from a separate StartInterval LaunchAgent. For
systemd, prefer a timer/service pair that runs the same curl probe and
systemctl --user restart hermes-webui.service on failure.

The accept_loop.requests_total value should increase when probes arrive. If
it stays flat while the process is still alive, the server accept loop is not
making progress; capture logs/thread samples before restarting if you are
collecting diagnostics for a bug report.


Troubleshooting (`docs/troubleshooting.md`)

Troubleshooting

Concrete diagnostic flows for the most common failure modes when running Hermes WebUI. Each entry has the symptom, the diagnostic commands you should run before opening an issue, and the fix that has worked for past reporters.

If your symptom isn't listed and the diagnostics don't narrow it down, file a bug at https://github.com/nesquena/hermes-webui/issues — include the relevant command output after redacting secrets, private paths, full .env files, full auth.json files, cookies, tokens, and password hashes.


Requests succeed but access records disappear during agent work

The server emits [webui] JSON access records to its original process stdout,
using a private duplicate captured before agent imports. This keeps request and
HTTP error logs visible when an in-process tool redirects or closes sys.stdout.
Logging failures still must not interrupt HTTP responses.

Check the service's captured stdout (for example, docker logs <container> or
the journal for your WebUI unit). Verify successful POST /api/chat/start and
GET /api/chat/stream records, not only health probes or rejected requests.
Records include method, path, status, and ms: elapsed time until response
headers, not the lifetime of an SSE stream or the agent turn. A missing record
before response headers does not distinguish a pending request from a logging
failure. Keep the launcher's output destination open for the process lifetime.

Scheduled cron execution belongs to the Hermes Agent gateway scheduler, not the
WebUI HTTP server. Investigate cron outcomes in the gateway's logs and the
active Hermes home's cron/executions.db; WebUI access records show HTTP cron
management requests, not every scheduled execution.

"AIAgent not available -- check that hermes-agent is on sys.path"

Symptom. WebUI starts, shows the chat interface, but every chat request fails immediately with this error in the response or the server log. As of v0.51.6 the error includes a diagnostic block with the running Python interpreter, the relevant sys.path entries, and the most-common fix; on older versions the message is bare.

Why it happens. The WebUI imports the agent class at chat time via from run_agent import AIAgent. That import only succeeds if the running Python's sys.path contains either the hermes-agent checkout or a pip-installed copy of the agent. Four common failure modes:

  1. Agent installed but not on sys.path. Most common. The agent is checked out somewhere (e.g. ~/Programmes/hermes-agent), the WebUI was launched with a Python that doesn't know about it, and there's no pip install -e . linking the two.
  2. Symlink with a typo or wrong target. A symlink to the agent looks correct on ls, but readlink resolves to a path that doesn't exist or doesn't contain agent/__init__.py.
  3. HERMES_WEBUI_AGENT_DIR set to the wrong directory. Override env var beats auto-discovery and points at a directory that has no agent code.
  4. Agent installed as root, under the FHS layout. When the Hermes Agent installer runs as root on Linux it places the agent at /usr/local/lib/hermes-agent (CLI linked into /usr/local/bin), not ~/.hermes/hermes-agent. Older bootstrap.py didn't probe that path, so it built a WebUI-only .venv and failed at launch with "Python environment cannot import both WebUI dependencies and Hermes Agent." git pull to update the WebUI (current bootstrap.py auto-discovers the FHS layout and follows the hermes launcher to the agent), and let the launcher pick the interpreter: when relaunch is enabled (the default: HERMES_DISABLE_LAZY_INSTALLS unset, updateMechanism=self, checkout with .git/pyproject.toml), launch preparation re-exec's a non-store interpreter into the store python before activation, so leaving HERMES_WEBUI_PYTHON unset lands on the managed interpreter. When the service suppresses relaunch instead, no re-exec corrects the seed interpreter, and an unset override falls through discover_launcher_python() to whatever non-managed python it finds (agent_dir/venv/bin/python if it exists, else the WebUI .venv, else system python3) — every turn then fails while the pre-flight check still passes. Those services must resolve the store python fresh at every start and export it as HERMES_WEBUI_PYTHON (see step 2) — never the in-tree venv, and never a saved path of any kind — see Hermes package-managed runtime bootstrap order.

Step 1 — confirm the agent location

bash
# If you have ~/hermes-agent (the default location):
ls -la ~/hermes-agent
readlink ~/hermes-agent          # if it's a symlink, where does it resolve?
ls ~/hermes-agent/agent/__init__.py 2>&1

The third command must succeed (the file must exist). If it fails, your symlink is broken or pointing at a directory that's missing the agent module — fix that first.

Step 2 — confirm the WebUI is using the right Python

bash
cd ~/hermes-webui && ./start.sh 2>&1 | grep -iE 'agent|python|hermes_webui_python' | head -20

The startup log shows how the launch resolved. If the agent dir is empty or the Python is the wrong one, set the override:

bash
export HERMES_WEBUI_AGENT_DIR=/absolute/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/absolute/path/to/working/python
./start.sh

For a source checkout that path is the agent venv python (.../hermes-agent/venv/bin/python). For a package-managed install with relaunch enabled (the default: HERMES_DISABLE_LAZY_INSTALLS unset, updateMechanism=self, checkout with .git/pyproject.toml), leave HERMES_WEBUI_PYTHON unset whenever the launcher discovers the agent on its own — managed activation selects and leases the committed generation on every launch. Never persist a resolved path — neither a generation path (installs/<key>/environments/<hash>/venv/bin/python, garbage-collected once unselected) nor a store path (tools/python-*, superseded on managed-python updates): both stop being the interpreter the Agent runs on. If the service suppresses relaunch (e.g. it sets HERMES_DISABLE_LAZY_INSTALLS=1), leaving the override unset re-selects a non-managed python — discover_launcher_python() prefers agent_dir/venv/bin/python when it exists (else the WebUI .venv, else system python3) and the pre-flight's pure-Python check passes on it while every turn fails. Those services must resolve the store python fresh at every start instead: hermes --print-runtime-command prints a JSON array whose element zero is the store python, so decode it in the service's own launch step (a systemd ExecStart wrapper or EnvironmentFile generator) and export it as HERMES_WEBUI_PYTHON (Python 3 is already required, so its standard library does the decoding):

bash
HERMES_WEBUI_PYTHON="$(hermes --print-runtime-command \
  | python3 -c 'import json, sys; print(json.load(sys.stdin)[0])')" || exit 1
[ -x "$HERMES_WEBUI_PYTHON" ] || exit 1
export HERMES_WEBUI_PYTHON

Assign, check, then export: export VAR="$(…)" succeeds even when the command
substitution fails, so a launcher that can't find hermes would otherwise start
with an empty HERMES_WEBUI_PYTHON instead of stopping.

A wrong extraction (e.g. cut -d, -f1) yields a JSON-quoted fragment instead of a path that fails loudly when the launcher execs it — never a saved path, never the pre-PM in-tree venv — see Hermes package-managed runtime bootstrap order.

Step 3 — install the agent in editable mode

This is the most common fix and resolves the original issue #1695:

bash
cd /path/to/hermes-agent          # the directory holding pyproject.toml + the agent/ module
pip install -e .                  # use the same python that runs the WebUI

Then restart the WebUI:

bash
cd ~/hermes-webui
./start.sh

Step 4 — verify by importing manually

If steps 1-3 still don't work, check whether the WebUI's Python can import the agent at all:

bash
$HERMES_WEBUI_PYTHON -c "from run_agent import AIAgent; print('ok')" 2>&1

(Replace $HERMES_WEBUI_PYTHON with the actual Python path from step 2 if the env var isn't set.) If this prints ok, the agent IS on sys.path for that Python. It does not prove the WebUI will serve a turn: a bare run_agent import pulls in no compiled extensions, so this probe passes on an interpreter the server cannot run on — see Hermes package-managed runtime bootstrap order. On installs whose agent ships hermes_bootstrap.py, check the activated layer too by importing a compiled extension after the bootstrap import:

bash
PYTHONPATH=/path/to/hermes-agent $HERMES_WEBUI_PYTHON -c "
import sys
import hermes_bootstrap                                        # activates the committed dependency environment
print(sys.executable)
from run_agent import AIAgent
from pydantic_core._pydantic_core import SchemaValidator        # compiled: fails on an ABI mismatch
print('ok')"

(On agents without hermes_bootstrap.py the bare probe above is the whole check — server startup supports those legacy checkouts, so don't gate them on the compiled import.) The agent's launch preparation re-exec's a non-store interpreter into the store python before activation, so run this probe the way the service runs: with the service's HERMES_HOME, and with HERMES_DISABLE_LAZY_INSTALLS=1 only if the service itself sets it. That variable (plus a non-self update mechanism, a checkout without .git/pyproject.toml, or a failed launch preparation) suppresses the re-exec — under those conditions the probe tests the launcher's interpreter directly, which is exactly the mismatch detector. If the bare probe printed ok but this one fails with No module named 'pydantic_core._pydantic_core', the interpreter is on the wrong side of an ABI mismatch: fix the launcher's interpreter — HERMES_WEBUI_PYTHON unset when relaunch is enabled, otherwise the store python resolved fresh at every start (see step 2). Reinstalling or repairing pydantic changes nothing, because both environments are complete in isolation.

If this fails, import run_agent itself is broken — check that the agent's pyproject.toml lists run_agent as a top-level module or that the agent dir is on PYTHONPATH:

bash
PYTHONPATH=/path/to/hermes-agent $HERMES_WEBUI_PYTHON -c "from run_agent import AIAgent; print('ok')"

If adding PYTHONPATH fixes it, persist the path either via pip install -e . (preferred) or by setting HERMES_WEBUI_AGENT_DIR to that directory.

When to file a bug

If after running steps 1-4 the import still fails and pip install -e . succeeded and PYTHONPATH=... python -c "from run_agent import AIAgent" succeeds — that's a real WebUI bug. File at https://github.com/nesquena/hermes-webui/issues with:

  • The output of every command in steps 1-4
  • The full diagnostic block printed by the WebUI's ImportError (v0.51.6+)
  • Your OS, Python version, and how the agent was installed
  • The interpreter your launcher resolved (or readlink /proc//exe for a systemd unit)

Hermes package-managed runtime bootstrap order

For package-managed Hermes installs, the server activates the discovered Agent's
hermes_bootstrap dependency layer before importing WebUI modules that need
third-party packages. It must not import run_agent at that point:
api.config first selects the active profile, then profile-sensitive Agent
application modules can be imported. Importing skills under the launch/base home
before selecting a named profile can disable their context-local home resolution
and force turns and model-catalog scopes into the legacy whole-turn lock.

The dependency layer retains Agent-owned activation and re-exec behavior; WebUI
does not choose generation directories or install into an obsolete Agent venv.
Legacy Agents and browser-only fixtures without hermes_bootstrap.py skip this
early activation. A failure inside a present bootstrap is logged as a warning
and startup continues, as it did when the Agent import was lazy, so the UI,
diagnostics and updater stay reachable; the Agent's own relaunch or repair exit
still stops the process. The interpreter compatibility probe may still import
run_agent in its disposable subprocess; that import must not leak into server
startup. If a restart fails, inspect the current service journal and selected
interpreter. This ordering repair does not remove the static fallback lock or
change cross-profile credential handling.

The launcher must still hand bootstrap.py an interpreter on the same side of the
Python ABI as the committed dependency generation — for example, the pre-PM
in-tree venv (3.11) beside a 3.14 generation. WebUI does not choose the
generation, but the interpreter it starts with decides whether that generation can
load: activate_dependencies() replaces sys.path with the generation's
site-packages, so a process built for another minor version starts, serves /health,
and then fails on every turn, with every MCP server reporting a connect failure
in the same breath:

text
Failed to initialize OpenAI client: No module named 'pydantic_core._pydantic_core'
module 'tools.mcp_tool' has no attribute 'StdioServerParameters'
MCP server '<name>' requires HTTP transport but mcp.client.streamable_http is not available

Those are the same ABI failure re-wrapped several modules deep, not a broken mcp
package and not broken MCP servers — do not upgrade or disable MCP servers over
them. This is also the false-negative in step 4's probe above: the bare probe's
run_agent import pulls in no compiled extensions, so it passes on an interpreter
the server cannot run on.

For a package-managed install the fix is to stop pinning an interpreter, not to
pin a better one — provided relaunch is enabled (the default:
HERMES_DISABLE_LAZY_INSTALLS unset, updateMechanism=self, checkout with
.git/pyproject.toml). Leave HERMES_WEBUI_PYTHON unset whenever the launcher
discovers the agent on its own: the agent's launch preparation re-exec's a
non-store interpreter into the store python (~/.hermes/tools/python-*) before
activation, and activation then selects and leases the committed generation —
the generation python itself is re-exec'd into the store python, so a saved
generation path is neither the interpreter the Agent runs on nor stable across
updates. Never persist a resolved path: saved generation paths
(installs/<key>/environments/<hash>/venv/bin/python) are garbage-collected
once unselected, and a saved store path (tools/python-*) is superseded on the
next managed-python update — in both cases the next start.sh launches an
interpreter the Agent no longer runs on. If an override is unavoidable — or the
service suppresses relaunch (e.g. it sets HERMES_DISABLE_LAZY_INSTALLS=1, in
which case an unset override falls through discover_launcher_python() to a
non-managed python) — resolve the store python fresh at every start and export
it as HERMES_WEBUI_PYTHON. hermes --print-runtime-command prints a JSON
array whose element zero is the store python; decode it in the service's own
launch step (a systemd ExecStart wrapper or EnvironmentFile generator):

bash
HERMES_WEBUI_PYTHON="$(hermes --print-runtime-command \
  | python3 -c 'import json, sys; print(json.load(sys.stdin)[0])')" || exit 1
[ -x "$HERMES_WEBUI_PYTHON" ] || exit 1
export HERMES_WEBUI_PYTHON

Assign, check, then export: export VAR="$(…)" succeeds even when the command
substitution fails, so a launcher that can't find hermes would otherwise start
with an empty HERMES_WEBUI_PYTHON instead of stopping.

That resolves per-launch instead of persisting a versioned path. Run it with
the service's HERMES_HOME when the install uses a non-default home (per-user
installs live at ~/.hermes/hermes-agent; the FHS path above is the
root-on-Linux layout only), and with the service's HERMES_RUNTIME_DIR when it
sets one — the runtime override also steers store_root(), so a resolver run
without it can point at a different store than the service uses.

The bare store python needs no workaround either: run through bootstrap.py's
pre-flight _python_can_run_webui_and_agent() probe it behaves like any managed
interpreter, because that probe itself triggers the same launch preparation and
activation. From the outside, /health and /api/health/agent both return 200
while this is broken; only a real turn (POST /api/chat, or
POST /api/chat/start + GET /api/chat/stream) constructs the OpenAI client
and surfaces the mismatch.

The native Windows launcher also decides which Agent root that bootstrap runs
from, so the two have to agree. start.ps1 normally keeps the source-first
order from api.config._discover_agent_dir, but it repairs a few displaced
layouts: a bare source checkout, the repo sibling, an Agent root (source or
pip-style) at the repo parent, or %USERPROFILE%\hermes-agent, when that selection has no in-root
venv and a later master-order install exists. For the layout-fallback cases the
install may win even without its own venv (deps already importable); otherwise
the install needs a venv\Scripts\python.exe. A source checkout that has its
own venv keeps priority, since it is launchable on its own.
HERMES_WEBUI_AGENT_DIR remains authoritative over both.

Current Hermes managed environments ship ruamel.yaml and may not include
PyYAML. WebUI reads and writes YAML through api/yaml_compat.py, which uses
PyYAML when it is importable and falls back to ruamel.yaml otherwise, and the
bootstrap probe accepts either backend.


Browser becomes unresponsive while switching running conversations

If /health is healthy and active_runs still contains the tasks, a frozen browser does not mean the backend workers have crashed. Reconnecting to a long reasoning stream can replay many chunks at once; repainting the activity scene for every chunk can block the browser main thread.

Reasoning deltas remain in each conversation's INFLIGHT state. Display updates share the answer's coalesced render scheduler, with synchronous tail flushes at interim-answer, tool, and terminal boundaries. Queued renders check the current conversation and stream ownership before updating the pane. Transport recovery paints pending assistant prose before cancelling the queued frame, so already received text stays visible while reconnecting. Reloading the page reconnects to existing tasks; the backend does not need to restart just because the browser froze.

"Response interrupted." marker keeps saying "no agent output was recovered"

Symptom. After a live response stream stops before a turn completes (manual restart, OOM, crash, browser/SSE disconnect, lost worker bookkeeping, …), the affected chat shows an **Response interrupted.** marker. If the run-journal for that turn is already visible on disk, the marker says the partial output was recovered; if not, it preserves the user turn and says no agent output was recovered yet.

Why. Sidecar repair re-checks the run-journal after it detects a stale stream and uses the result as a one-shot signal. On WSL2 (9p / DrvFs) and on some network-backed setups, the run-journal .jsonl is written by the stopped worker but the WebUI process reads it through a page-cache state that has not yet seen those writes — recovery returns "empty" and the marker would otherwise be baked permanently. The fix introduces a lazy retry path: when sidecar repair cannot read visible output but knows the stream id, it stores a _pending_journal_recovery flag on the marker and re-attempts recovery from get_session() until the journal becomes readable (or the retry budget is exhausted).

Interruption classes. The WebUI now keeps the user-facing cases separate instead of implying every stale stream was a restart:

  • Browser/SSE connection interrupted — the live browser EventSource transport dropped. The UI reports Connection interrupted and tries status/replay/session restore before showing the final browser-side notice. Chat and gateway SSE errors also POST a small sanitized diagnostic event to /api/client-events/log (source, session id, stream id, readyState, visibility, online state, path without query string) so server logs can distinguish browser transport loss from backend worker loss.
  • Lost worker bookkeeping — the stream id is gone and the worker registry no longer has an active run. Recovery markers carry interruption_cause: "lost_worker_bookkeeping" and /api/chat/stream/status reports terminal_state: "lost-worker-bookkeeping" for non-terminal journals that are no longer active.
  • Stream/run split-brain — the stream is gone but ACTIVE_RUNS still lists the worker. Recovery markers carry interruption_cause: "stream_run_split_brain" so the transcript says this is a bookkeeping split-brain rather than a restart.
  • Process crash/restart — SERVER_START_TIME is newer than pending_started_at, meaning the WebUI process started after the turn began. Recovery markers carry interruption_cause: "process_restart" and explicitly say the process-start evidence points to a crash or restart.

Diagnostic.

The on-disk locations below assume the default ~/.hermes/webui state directory. If you override it via HERMES_WEBUI_STATE_DIR, substitute that path for ~/.hermes/webui in every step.

  1. Identify the affected session id and stream id from the marker. The marker JSON lives at ~/.hermes/webui/sessions/<sid>.json; after the fix it shows them on the _journal_retry_stream_id key. Pre-fix sessions only carry the legacy wording, with no retry meta.
  2. Check whether the run-journal contains real events:
    bash
    ls -la ~/.hermes/webui/sessions/_run_journal/<sid>/<stream_id>.jsonl
    head -2 ~/.hermes/webui/sessions/_run_journal/<sid>/<stream_id>.jsonl
    If the file exists and contains token / tool events, the lazy-retry path will pick them up the next time the session is opened.

Fix. Reload the session in the browser. On the next get_session() call the marker is re-evaluated; if the journaled events are visible on disk the marker promotes to "The partial output above was recovered from the run journal …" wording and the journaled assistant text + tool cards land above the marker in chronological order. No manual sidecar editing is required.

Trigger. Sidebar metadata polling is intentionally not enough to run this self-heal. Requests such as /api/session?messages=0&resolve_model=0 load the session with metadata_only=True, skip the full messages array, and therefore skip the lazy journal retry helper. Click/open the affected conversation so the message panel performs a full messages=1 load; that full render is what re-checks the journal and can promote the marker.

Caps. The lazy retry path gives up after 12 failed attempts or 24h of wall-clock age, at which point the marker is demoted to a neutral "Partial output may have been lost." wording so the "reload to retry" prompt doesn't linger forever for genuinely lost journals.

When to file a bug. If, after the fix, you see the lazy-retry wording ("Recovering the partial output from the run journal — reload this session to retry.") but reloading the session never promotes it to the recovered wording even though the .jsonl clearly contains token events, capture the marker JSON and the run-journal file and file a bug.


"Context compression exhausted" after a long-running turn

Symptom. A long-running session, often with many tool calls or a small
context-window model, ends with a Context compression exhausted error instead
of a final answer. The message includes a recovery action labeled Start focused continuation.

Why. Automatic compression could not shrink the current conversation enough
to continue safely in the same model-facing context. The exhausted session is
terminal: sending a bare "continue", "go on", or "继续" would usually replay the
same oversized state and fail again, so the WebUI points the user to a focused
linked continuation instead.

Diagnostic.

  1. Open the session JSON under your WebUI state directory, for example:
    bash
    jq '.recommended_recovery_action, .compression_recovery' \
      ~/.hermes/webui/sessions/<session_id>.json
  2. A recoverable exhausted turn should report:
    • recommended_recovery_action: "start_focused_continuation"
    • compression_recovery.terminal_state: "compression_exhausted"
    • the final assistant error message carrying _compressionRecovery

Fix. Use the Start focused continuation action in the exhausted message.
The new linked session preserves the workspace, model, profile, project, and
toolset lane, but intentionally starts with an empty model-facing transcript so
the oversized exhausted tail is not replayed. After the new session opens,
describe the next narrow task explicitly instead of sending a bare continuation.

When to file a bug. File a bug if the exhausted message has no recovery
action, the action creates a session with the old oversized context/messages
replayed into the model-facing transcript, or a bare "continue" starts another
turn in the exhausted session instead of being blocked with recovery guidance.


Installed PWA opens to a blank screen after an update

Symptom. The installed PWA or home-screen app opens to a blank screen after a WebUI update, while the same URL often works again in a normal browser tab.

Why. Reverse proxies are supported, but proxy basic auth can challenge the same-origin sw.js, manifest, or versioned static/* fetches the installed app needs while its service worker updates the shell.

Diagnostic.

  1. Open the same WebUI URL in a regular browser tab and confirm whether it loads there.
  2. Check reverse-proxy logs for 401 responses on /sw.js, /manifest.json, or versioned /static/* assets during the update.
  3. Temporarily remove proxy basic auth and use WebUI's built-in password. If the blank screen stops after the next update, the proxy auth challenge was the trigger.

Fix. Prefer WebUI's own password for installed PWAs. If you keep proxy basic auth, configure it so the same-origin service-worker and shell update fetches can complete. If the installed shell is already blank, clear site data for the Hermes origin, then reopen or reinstall the PWA after that site-scoped cleanup.

When to file a bug. File a WebUI bug if the blank screen still reproduces without proxy basic auth, or after the proxy allows the same-origin service-worker and shell update fetches through.


"Hermes Agent was updated while Hermes WebUI was running"

Symptom. An action that uses the in-process Agent runtime stops with a message telling you to restart Hermes WebUI manually. This can happen after hermes update, a Git checkout/pull in the Agent source tree, or another tool updates Hermes Agent without restarting the already-running WebUI backend.

Why. WebUI imports run_agent.AIAgent into its long-lived Python process. Continuing after a known Agent Git revision changes could combine cached modules from the old revision with source read from the new revision. Local Agent-backed actions return a retryable 409 agent_runtime_stale with restart_scheduled: false before accepting a new turn. Gateway- and runner-owned chat keep their existing runtime ownership. Non-Git Agent installs preserve their existing behavior because there is no revision identity to compare; losing a previously known revision remains fail-closed.

Revision checks allow up to 10 seconds total across the three local Git reads
(worktree root, tracked module, and HEAD). Each read receives only the time
remaining before one monotonic deadline, so slow reads that finish within the
total budget do not falsely look like a changed runtime. An exhausted budget or
failed read still blocks a previously identified Git runtime; this does not
bypass the revision guard or schedule an automatic restart. This is a per-check
budget, not an end-to-end chat-request timeout; chat admission can check twice.

Diagnostic. The stale-runtime response includes agent_update_state, also preserved in asynchronous compression error status:

Value Observation
active A recent Agent update marker names a live PID.
incomplete An Agent recovery marker exists in the loaded checkout or configured venv installation.
stale The update marker names a dead PID or is older than the diagnostic age limit.
unknown Marker contents, PID liveness, or recovery-marker presence cannot be read or classified.
unverified No active or recovery marker was found. Update completion and environment health remain unverified.

These are observations, not success receipts. Hermes Agent removes .hermes-update-in-progress on failed and interrupted exits too. A missing or stale marker, or a readable Git revision, does not prove a completed update or a healthy environment. WebUI only reads these markers; it does not remove or repair them.

Fix. Check the Agent updater's outcome and resolve any failed or incomplete Agent update first. Once the Agent checkout and environment are healthy and no updater is running, restart WebUI using the same launch method that started it:

bash
./ctl.sh restart
# Or, for a user systemd service:
systemctl --user restart hermes-webui.service

For a foreground python3 bootstrap.py, stop it with Ctrl-C and start it again. Restarting the whole computer or WSL is not required when restarting the WebUI backend succeeds. Retry the action after restarting the backend; refreshing the browser alone does not replace its imported Agent modules.

Automatic restart prerequisite. Revision mismatch does not schedule a WebUI restart. Safe automation requires an Agent-owned terminal success receipt bound to the exact update transaction, final revision, and healthy environment, plus an Agent-owned atomic handoff or lease that excludes new mutations across process replacement (or an Agent updater that performs the restart itself). No such public contract is verified for this integration. Repeated readiness checks followed by os.execv() leave a race; WebUI's own update lock does not exclude an external Agent updater. Explicit updates initiated through WebUI retain their existing behavior and are outside this revision-mismatch guard.

When to file a bug. File a WebUI bug if the restart-required message appears even though the Agent revision did not change or become unreadable, or if a clean WebUI restart still produces the same import error. Include the launch method, WebUI and Agent revisions, the marker diagnostic, and sanitized error text.


Agent sessions list slowly (or the `state.db` read index is missing)

Symptom. The sidebar's imported/CLI session list takes seconds per refresh on a large Hermes profile, or a log line says a state.db read failed. Sessions still appear; nothing is lost.

Why. Every WebUI reader of the agent's state.db (session listing, transcript reads, lineage, gateway watcher, cron sidebar, insights, health) opens it strictly read-only (file:...?mode=ro). A reader never upgrades to a write-capable handle and never creates an index: on a multi-GiB messages table CREATE INDEX holds the SQLite writer lock for minutes and stalls the agent streaming into the same WAL database. When the agent's standard idx_messages_session index is missing (older agent, hand-rebuilt or re-imported DB), listings degrade to a bounded one-pass pre-aggregation — slower than the indexed seek, but read-only. A read-only open failure propagates to the caller's existing error boundary instead of silently reopening the file writable.

A primary state.db read failure keeps the existing availability behavior: a complete same-generation stale snapshot wins when available, then the independently bounded eight-entry last-known-good cache keyed without the volatile database fingerprint preserves rows across idle and streaming-frozen refreshes. In the all-profiles view, a profile whose primary read fails makes the aggregate non-authoritative, so it is never published to either cache.

The additive cron, webhook, and kanban passes, and the project-assigned recovery and unassigned refill passes, have a different failure boundary. If one of those later reads is temporarily unavailable, WebUI serves the primary rows it already loaded as an explicitly incomplete, fresh projection for that request and does not cache it; the next poll retries the optional pass. Errors while creating project metadata or building an optional row are debug-logged and skipped without misclassifying projects.json as an unavailable state.db. In all-profiles mode, an optional-pass failure therefore keeps both the affected profile's primary rows and healthy profiles' rows instead of replacing them with a stale aggregate. Claude Code discovery remains one global scan, independent of profile database availability, and runs only for unfiltered or explicit claude-code requests.

Diagnostic.

bash
sqlite3 "file:$HOME/.hermes/state.db?mode=ro" "PRAGMA index_list(messages)"

idx_messages_session should be listed. If it is not, the agent has not created it and WebUI will not create it for you.

Fix. Create the covering read indexes in an explicit drained maintenance window: stop the agent (and any gateway/cron runner) writing to that state.db, then run:

bash
python3 scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db --confirm-drained
  • --confirm-drained is mandatory: it is your assertion that no agent turn is running against the database. The tool does not verify it.
  • --lock-file PATH (optional) additionally holds an exclusive non-blocking lock on PATH (flock on POSIX, msvcrt.locking on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without --lock-file no lock primitive is required, so the script runs on native Windows as well. On Windows the lock covers byte 0 of PATH; a new or empty lock file is initialised with one byte first (an existing lock file is never rewritten).
  • The tool opens the database mode=rw (never rwc): a mistyped path raises instead of creating an empty database. Indexes are created inside one BEGIN IMMEDIATE transaction and rolled back on any error.
  • It is idempotent and prints a JSON status per index (created / existing / skipped). skipped means this database's schema lacks a column that index keys on (an older agent); the indexes the schema does support are still created. An existing index with a different table, key shape or collation is reported as Incompatible index and never replaced; an index that is not covering (EXPLAIN QUERY PLAN) is reported as Index is not covering.
  • Windows UNC profiles (HERMES_HOME=\\server\share\...) are supported: readers and this tool build the empty-authority URI file:////server/share/state.db that the bundled SQLite accepts.

When to file a bug. File a WebUI bug if the listing stays slow after the tool reports existing for idx_messages_session, if the tool reports Incompatible index on an untouched agent-created database, or if a read-only open fails on a local path. Include the tool's JSON output, the PRAGMA index_list(messages) result, and the sanitized error text.


404 after login when password auth is enabled

Symptom. After enabling password authentication (HERMES_WEBUI_PASSWORD), logging in redirects to /sessions and the browser shows a 404 not found error instead of the chat interface.

Why. The server-side redirect after login targets /sessions (plural), but that path was missing from the explicit SPA-shell allowlist in handle_get(). Without auth the bug is invisible because the SPA handles /sessions client-side and the server route is never hit — only the server-side post-login redirect exposes it.

Fix. /sessions is now included alongside / and /index.html in the set of paths that serve the SPA shell. No configuration change is needed.


Update fetch cannot create a remote-tracking ref

Symptom. After a remote branch rename, Git reports that
refs/remotes/origin/onboarding exists and it cannot create
refs/remotes/origin/onboarding/next (Git older than 2.51), or only
some local refs could not be updated; try running 'git remote prune origin'
(Git 2.51 and newer, which batches fetch ref updates).

Recovery. For the updater's fetch origin commands and the default clone
mapping (+refs/heads/*:refs/remotes/origin/*), the runner retries this collision
once with --prune --no-prune-tags and an explicit remote-tracking-only
mapping, so config changes during recovery cannot widen the prune destinations.
Git removes stale remote-tracking refs before fetching their replacements;
local branches, local-only tags, and
working-tree edits remain intact. Custom mappings (including mappings into
local tags or branches), explicit refspecs, and other failures retain their
normal diagnostics and require manual inspection instead of automatic pruning.

Update check reports a Git authentication or fetch failure

Symptom. The update status is stale or reports fetch failed, Authentication failed, or
could not read Username, or unable to get password from user (Git 2.47+).
Git's own prompts are disabled; credential-helper UI depends on the helper.

Why. Update checks are unattended. WebUI removes inherited askpass, SSH-command, proxy, and Git
config injection settings; disables checkout-controlled askpass and credential helpers; and forces
SSH batch mode. Generic and URL-scoped credential helpers from trusted user and system Git config
remain available when declared directly in the primary system/global files.
Git Credential Manager also receives GCM_INTERACTIVE=never and
credential.interactive=false: cached credentials may authenticate a check, but a GCM cache miss
must fail without opening its GUI or browser login. Git itself honors
credential.interactive starting in 2.47; other credential helpers may ignore
these controls and still open a browser or GUI. These controls follow
GCM's environment contract
and configuration contract. include and
includeIf are not followed for credential helpers, core.sshCommand, or ssh.variant:
included files may be checkout-controlled even when Git labels their scope global. Move these
settings into the main user/system config if needed. The explicit scope reads also work on
Git versions before 2.26. A trusted user/system core.sshCommand is retained with the batch option for
its trusted or detected SSH variant (-oBatchMode=yes for OpenSSH, -batch for PuTTY/Plink),
as is an inherited SSH_AUTH_SOCK. A checkout-controlled core.gitProxy is rejected only when it applies
to the active git:// remote's host; ordinary git:// remotes without an applicable override remain
supported. Push checks follow branch.<name>.pushRemote, remote.pushDefault,
branch.<name>.remote, then origin. Checks cover every selected remote pushurl,
or every url when no pushurl exists; fetch/pull use only the first fetch URL.
Any applicable checkout-controlled proxy blocks the entire push before it starts.
A trusted SSH command is preserved/probed when any actual push destination uses SSH.
Remote-helper forms such as ext::, ssh::, and https:: are rejected because the
transport prefix names a helper command.

Diagnostic. Run the project diagnostic for each checkout named by the update status:

bash
python3 scripts/diagnose_update_git.py /path/to/checkout

The diagnostic reads the origin, accepts built-in HTTP(S), SSH, and git:// remote forms,
applies the update runner's proxy guard in the original checkout, then probes the captured
URL outside the checkout so repository-controlled remote helpers and URL rewrites cannot run. It
reports fixed failure categories instead of relaying Git or credential-helper output, and redacts
checkout paths, origin paths, URL credentials, tokens, and secret query values.

Fix. Configure a non-interactive user/system credential helper for a private HTTPS origin, or use
an SSH origin with a key already loaded in the SSH agent seen by WebUI. Restart WebUI if necessary so
it inherits the correct SSH_AUTH_SOCK, then rerun the diagnostic and update check. Custom SSH
commands must declare or auto-detect as OpenSSH, Plink, PuTTY, or TortoisePlink; Git's simple variant
fails closed. Only SSH destinations trigger the five-second, stdin-disabled -G
configuration probe for custom-named commands; local paths and HTTP(S) never invoke
the SSH wrapper. The probe uses Git's resolved shell rather than requiring sh on PATH;
only a successful probe enables OpenSSH batch mode. Explicit interactive BatchMode options
(including whitespace forms such as -o 'BatchMode no') fail closed rather than relying on
a later option to override OpenSSH's first-value semantics. Failed/unknown probes fail closed.

When to file a bug. File a WebUI bug if the diagnostic succeeds under the same user and
environment but the update check still fails, or if either path opens a credential prompt. Include
only sanitized hosts and errors; do not include credential-bearing URLs, tokens, or private paths.


"OpenCode Go model picker shows a model that errors when you send" (or is missing newly released models)

Symptom. One of two directions:

  1. A model selected from the OpenCode Go group fails on the first message (model not found, Model is unavailable, or a region error), even though the picker offered it.
  2. A newly released OpenCode Go model does not appear in the picker at all, and must be typed into the Custom Model ID box.

Why. The Go picker follows the live Go-tier catalog (https://opencode.ai/zen/go/v1/models) whenever the installed Hermes Agent is v0.20.5 or newer. That endpoint advertises a superset of what a given tier, key, or region can actually serve — an id can be listed and still fail on send. Conversely, an id the endpoint serves but the WebUI's static fallback list predates is missing when the live path is unavailable (Agent older than v0.20.5, probe failure, or offline); the static list mirrors Hermes core's curated Go catalog and can lag new releases by design.

Diagnostic. Check which path is feeding your picker:

bash
hermes --version          # live path requires >= 0.20.5
curl -sS https://opencode.ai/zen/go/v1/models \
  -H "Authorization: Bearer $OPENCODE_GO_API_KEY" | head -50

Interpret the two together:

  • Failing model absent from the curl output → it was delisted upstream (for example ox-alpha-free, removed from the relay on 2026-09-09). A picker can still offer it from a stale catalog merge: Hermes core merges its own curated Go list into the live result, and every Agent release through v0.21.1 (tag v2026.9.7) still carries the delisted id in that list — verified at runtime against v0.21.0, where the live path serves 37 ids including ox-alpha-free. The removal is committed on core main (2026-09-09 sync) but no released Agent version includes it yet — and main reports the same 0.21.1 version string as the stale tag, so a version number alone cannot tell you whether the fix is in. Until a release notes the 2026-09-09 catalog sync, the verified workaround is the config allowlist (below); selecting the dead entry is harmless to other models (it errors on send, nothing else).

  • Failing model present in the curl output → the relay lists it but your tier/region cannot serve it; the picker is behaving correctly. Pin the models you actually use with an explicit allowlist in config.yaml, which takes precedence over both the live catalog and the fallback:

    yaml
    providers:
      opencode-go:
        models:
          - kimi-k3
          - glm-5.3

    A lighter per-provider exclude capability is tracked in #7507.

  • Missing new model, Agent ≥ v0.20.5 → the live catalog is the source; refresh or check the endpoint with the curl above (cold rebuilds are also bounded by a 4-second foreground budget — the first picker open after a restart can serve the last-known list while the live rebuild finishes in the background, so re-open the picker once before concluding it's stale).

  • Missing new model, Agent older than v0.20.5 → the static fallback is serving by design; upgrade the Agent to ≥ v0.20.5 so the picker reads the live catalog.

When to file a bug. File a WebUI bug if a model fails on send and appears in the curl output for your key (a routing problem), or if a model is missing with Agent ≥ v0.20.5 and the live catalog reachable (fallback used when it should not be).


MCP panel shows another profile's servers, or "Live status for this profile is unavailable"

Symptom. With several profiles, the MCP settings panel of profile A shows a server as Active with a tool count while the tool inventory is empty (or lists profile B's tools); /reload-mcp on one profile stops the other profile's servers; or the MCP panel and the external Notes drawer show the notice "Live status for this profile is unavailable right now" and /reload-mcp answers "MCP runtime scope could not be confirmed".

Why. Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and /reload-mcp to the request profile (ARCHITECTURE.md §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into HERMES_HOME; Agents that predate hermes_constants.pin_process_hermes_home cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection.

Diagnostic commands.

bash
# runtime_scope: "profile" (bound), "legacy_process" (Agent without profile-scoped MCP),
# "unavailable" (scope could not be confirmed right now)
curl -s -b "hermes_profile=<profile>" http://127.0.0.1:8787/api/mcp/servers | python3 -m json.tool | grep -E '"(name|status|tool_count|runtime_scope)"'
python3 -c "import hermes_constants; print(hasattr(hermes_constants, 'pin_process_hermes_home'))"

Fix. unavailable while a turn is running is expected: refresh once the turn finishes. If it persists with no turn running, the Agent predates the process-home pin; upgrade Hermes Agent. legacy_process means the Agent has no profile-scoped MCP ledger at all; its /reload-mcp stays process-wide by design. After changing a profile's mcp_servers, run /reload-mcp from that profile: it only resets that profile's own connections and retries its failed servers.

When to file a bug. File a WebUI bug if runtime_scope is "profile" and a server is still reported Active with tools you cannot see in the inventory, or if /reload-mcp from one profile changes another profile's tool_count.


Other troubleshooting

This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: Symptom → Why → Diagnostic commands → Fix → When to file a bug.

Related references:

  • docs/supervisor.md — process-supervisor setup (launchd, systemd, supervisord, runit/s6) including the bootstrap supervisor-foreground flag.
  • docs/docker.md — Docker compose setup, common failure modes, bind-mount migration.
  • docs/wsl-autostart.md — WSL2 auto-start at login on Windows.
  • docs/EXTENSIONS.md — WebUI extension injection, security model, examples.

Why Hermes (`docs/why-hermes.md`)

Why Hermes

Hermes is a persistent, autonomous AI agent that runs on your server. It has layered memory that
accumulates across sessions, a cron scheduler that fires jobs while you're offline, and a
self-improving skills system that saves reusable procedures automatically. You reach it from a
terminal, a browser, or a messaging app — and it's the same agent with the same history every time.

This document explains the mental model, how Hermes compares to other tools honestly, and where
it is and is not the right choice.


The real problem: most tools are excellent in the moment and weak over time

Memory is no longer a differentiator on its own. ChatGPT, Claude, Cursor, and GitHub Copilot all
have some form of memory now. Anthropic, OpenAI, and Microsoft are all shipping scheduling and
agent features. The category boundaries that existed twelve months ago are blurring fast.

Hermes is not the only tool with memory or automation. It is the tool that makes those
capabilities durable, self-hosted, cross-surface, and cumulative on your own server. The
distinction that matters is not "has memory" vs. "has no memory" — it's whether context persists
across sessions automatically, whether execution happens on hardware you control, whether you can
reach the same agent identity from any device, and whether the system gets meaningfully better at
your specific workflow over time without manual configuration.

text
Session-scoped:   You -> [Tool] -> Answer -> Done
                  (some tools now carry memory, but the execution is stateless)

Persistent agent: You <-> [Hermes] <-> (memory, skills, schedule, tools, surfaces)
                  (runs on your server, accumulates context, acts on your behalf offline)

A note on convergence

The market is converging. Chat assistants are adding task scheduling and file connectors. IDE
tools are launching cloud agent modes. CLI tools are adding skills systems and mobile surfaces.
The lines between "assistant," "editor," and "agent" are dissolving.

This makes comparisons harder but also makes the question sharper: what actually matters when
every tool is claiming some version of every feature? For Hermes, the answer is synthesis. Any
single feature — memory, scheduling, messaging — is available somewhere else. The value is
having all of them in one self-hosted system, running continuously, with a persistent identity
that accumulates real knowledge of your stack over time.


The three pillars

1. Memory that compounds

Hermes has layered memory that survives every session, every reboot, and every model swap:

  • User profile — who you are, your preferences, your communication style, things you've corrected Hermes on
  • Agent memory — facts about your environment, your toolchain, your project conventions
  • Skills — reusable procedures Hermes discovers and saves automatically; it never has to relearn how to deploy your app, run your tests, or review a PR
  • Session history — every past conversation is searchable; Hermes can recall what you worked on last Tuesday

When you correct Hermes, it remembers. When it solves a tricky problem, it saves the approach.
When it learns your stack, that knowledge carries into every future session. You never configure
this manually — it happens in the background as a side effect of normal use.

2. Autonomous scheduling

Hermes can run jobs without you present — every hour, every morning, on any cron schedule. It
fires up a fresh session with full access to your memory and skills, runs the task, and delivers
the result wherever you want it: Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.

Things Hermes can do while you sleep:

  • Review new pull requests on your GitHub repo and post a full verdict comment
  • Send a morning briefing of news, markets, or anything else you track
  • Run your test suite and alert you if something breaks
  • Watch a competitor's blog for new posts and summarize them
  • Monitor a datasource and notify you when a threshold is crossed

The difference from cloud-scheduled alternatives is that the job runs on your server, with your
memory and skills, and your data never leaves your hardware.

3. Reach it from anywhere

Hermes runs on your server and is reachable from every surface: terminal over SSH, the web UI
(this project), and messaging apps including Telegram, Discord, Slack, WhatsApp, Signal, and
Matrix. Start a task from your phone, check it from the browser on your laptop, continue it in
a terminal on a remote server. The same agent, memory, and history follow you across all of them.


How AI tools are layered today

The old four-category model — chat, editor, CLI, agent — is too clean. These layers are actively
collapsing into each other. Here is a more honest picture:

Chat assistants (Claude.ai, ChatGPT) now have persistent memory, task scheduling, 50+ service
connectors, and in some cases full agent modes with computer use. They are no longer "just chat."

IDE tools (Cursor, Windsurf, Copilot) have shipped or are shipping cross-session memory,
cloud-based background agents, and in Cursor's case a full Automations platform with Slack
integration. Cursor v3.0 (April 2026) is explicitly agent-first.

CLI tools (Claude Code, Codex, OpenCode) have added hooks, skills, desktop app automations,
and multi-surface reach. Claude Code now spans terminal, IDE, desktop, and browser. Codex has
become a product family: CLI, IDE extension, desktop app, and Codex Cloud.

Persistent self-hosted agents (Hermes, OpenClaw) sit at the intersection: they combine the
tool-use power of CLI agents, the memory of chat assistants, the scheduling of automation
platforms, and the cross-surface reach of messaging integrations — running continuously on
hardware you own.

The question is not which category a tool belongs to. The question is which combination of
capabilities you actually need, where that execution lives, and whether the system gets better
at your specific context over time.


How Hermes compares

vs. OpenClaw

OpenClaw is the most direct comparison and the question most people ask first. Both are
open-source, self-hosted, always-on agents with persistent memory, cron scheduling, and messaging
app integration. If you're evaluating Hermes, evaluate OpenClaw too.

OpenClaw (MIT) is built around a Gateway control plane written in Node.js/TypeScript. It has the
widest messaging coverage in the space — 24+ channels including WhatsApp, Telegram, Signal,
iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, Google Chat, Feishu, Mattermost, IRC,
Nextcloud Talk, and more. It has native Chrome/Chromium control via CDP, voice wake words on
macOS and iOS, and a ClawHub marketplace with 10,700+ skills. The community is large (350k+
GitHub stars, 16,900+ commits) and growing.

Hermes is built in Python and centers on a self-improving agent loop rather than a gateway
control plane. The core architectural difference is in skills: OpenClaw skills are primarily
human-authored plugins installed from a marketplace. Hermes writes and saves its own skills
automatically as part of every session. When Hermes solves a problem a new way, it saves the
procedure and reuses it without any user effort. That's not a subtle distinction — it's the
reason Hermes gets meaningfully better at your workflow without you maintaining a plugin library.

Two practical differences worth knowing directly:

Stability. OpenClaw's GitHub issues and community forums document recurring update-breaking
regressions. Telegram integration was broken across multiple releases from early 2026 through
at least April 2026. The unofficial WhatsApp Web protocol OpenClaw relies on disconnects and
requires periodic re-pairing — this is in OpenClaw's own FAQ.

Security. ClawHub's open publishing model has been exploited at scale. Three separate audits in
early 2026 found serious problems: Koi Security (January 2026) linked 335 skills to a campaign
called "ClawHavoc" that delivered Atomic Stealer malware on macOS; Bitdefender found roughly
900 malicious packages representing about 20% of the ecosystem at the time; Snyk's "ToxicSkills"
report (February 2026) found malicious skills across roughly 4,000 scanned packages. China's
CNCERT issued a national warning about ClawHub. Hermes has no third-party marketplace and a
correspondingly smaller attack surface.

OpenClaw's genuine strengths are worth stating plainly: broader messaging coverage (iMessage,
LINE, WeChat, Teams, Google Chat — platforms Hermes does not support), native browser and
computer control via Chrome CDP, voice wake words, a larger community, and more third-party
integrations than Hermes. If those capabilities matter most, OpenClaw is worth a serious look.

Where Hermes fits better: you want an agent that self-improves from experience without managing
a plugin library, you work in Python and want the ML/data science ecosystem, you want a stable
deployment that doesn't break between updates, or you want a full web chat UI rather than a
control dashboard.

OpenClaw Hermes
Persistent memory Yes Yes
Scheduled jobs (cron) Yes Yes
Messaging app access Yes (24+ platforms, incl. iMessage/WeChat/LINE) Yes (many platforms)
Web UI Chat UI + control dashboard Full three-panel chat UI
Self-hosted Yes Yes
Open source Yes (MIT) Yes
Self-improving skills Partial (AI can generate; not the default loop) Yes (automatic, first-class)
Browser / computer control Yes (native Chrome CDP) Via shell / tools
Voice wake words Yes (macOS/iOS) No
Python / ML ecosystem No (Node.js) Yes
Orchestrates Claude Code / Codex No Yes
Multi-profile support Via binding-rule routing Yes (first-class named profiles)
Provider-agnostic Yes Yes
Update reliability Moderate (documented regressions) High
Memory inspectability Limited Yes (markdown files, editable)
Self-hosted autonomous execution Yes Yes

vs. Claude Code (Anthropic)

Claude Code is Anthropic's official agentic tool and one of the strongest options for focused
coding sessions. It has deep code understanding, shell access, file editing, and multi-step
reasoning. It has been expanding rapidly — it now spans terminal, IDE plugin, desktop app, and
browser surfaces — and the gap is closing in several areas.

What Claude Code has that's worth knowing:

  • Hooks system — 26 event types (SessionStart, PreToolUse, PostToolUse, Stop, and more) with
    4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); gives deterministic
    non-LLM control over the agent lifecycle
  • Plugins / Skills — installable via /plugin install, hot-reloaded from ~/.claude/skills,
    with a marketplace; includes the official ralph-wiggum plugin (/ralph-loop) for
    autonomous iteration toward a completion goal (distinct from /loop)
  • /loop — a native bundled skill, available in every session without any plugin, that runs
    a prompt on a repeating schedule within an active CLI session (polling/monitoring use case);
    session-scoped, dies when the terminal closes
  • Scheduling — cloud-managed cron (Anthropic infrastructure, minimum 1-hour interval) and
    desktop app scheduled tasks (run locally while the app is open, minimum 1-minute interval,
    full local file access); no self-hosted cron
  • Messaging channels — Telegram, Discord, and iMessage via the Channels feature (research
    preview, requires Bun runtime); Slack is the most-requested addition and has not yet shipped
  • Memory — CLAUDE.md and MEMORY.md for project-level context; auto-memory since v2.1.59+
  • Claude Cowork — a separate knowledge-worker product connecting 38+ services via MCP
    including Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more

Claude Code's source was briefly and accidentally made public in March 2026 before being taken
down. The CLI ships as minified/bundled TypeScript compiled with Bun — it is not open source.

Key differences that remain:

  • Scheduling requires cloud (Anthropic infrastructure, data off your hardware, 1-hour minimum)
    or the desktop app (runs locally, but the app must stay open — not a headless server process);
    neither runs as a server daemon the way Hermes cron does
  • Memory is project-file-based (CLAUDE.md / MEMORY.md plus rolling auto-memory); it doesn't
    automatically accumulate a cross-project knowledge graph the way Hermes does
  • Not provider-agnostic — routes through Anthropic, Bedrock, Vertex, or Foundry, but always
    a Claude model; you can't switch to GPT, Gemini, or a local model
  • Messaging channels are still a research preview, not production

Hermes can use Claude Code as a sub-agent. For large implementation tasks, Hermes can spawn
Claude Code to handle the heavy lifting and fold the result back into its own memory and history.

Claude Code Hermes
Persistent memory (automatic) Partial (CLAUDE.md / MEMORY.md + auto-memory v2.1.59+) Yes
Skills / hooks system Yes (26-event Hooks + Plugin/Skills marketplace) Yes (auto-generated from experience)
Scheduled jobs (self-hosted) No (cloud or desktop-app only) Yes
Messaging access Partial (Telegram/Discord/iMessage research preview; Slack not yet) Yes (many platforms, production)
Cowork connectors (Slack, Gmail, etc.) Yes (via Claude Cowork, separate product) Via agent tool use
Web UI Yes (claude.ai/code, Anthropic-hosted) Yes (self-hosted)
Provider-agnostic No (Claude models only) Yes (any provider)
Self-hosted scheduling No Yes
Open source No Yes
Background/cloud agent mode Yes (cloud-scheduled) Yes (self-hosted cron)
Runs as sub-agent of Hermes Yes N/A
Memory inspectability Partial (CLAUDE.md readable; auto-memory less so) Yes (markdown files)

vs. Codex CLI (OpenAI)

Codex CLI (Apache 2.0, ~60k GitHub stars) started as a straightforward terminal tool and has
expanded into a product family. It was rewritten from TypeScript to Rust. It now includes an IDE
extension, a desktop app with an Automations feature, and Codex Cloud for remote execution. A
Skills system is shared across surfaces. It supports 12+ built-in providers: OpenAI, Anthropic,
Google/Gemini, Mistral, Groq, Ollama, OpenRouter, LM Studio, Together AI, DeepSeek, xAI,
Azure OpenAI, and custom endpoints.

The CLI itself has no native scheduling (open feature request). Session continuity is available
via codex resume. Memory is session-history-based plus AGENTS.md project context — not a
living knowledge graph that accumulates across all your projects. No first-party messaging
integration. The Automations feature in the desktop app covers scheduled local tasks but doesn't
reach the cross-session, cross-surface continuity Hermes has.

Codex CLI Hermes
Persistent memory Partial (session history + AGENTS.md) Yes (automatic, layered)
Scheduled jobs Partial (desktop app Automations; CLI has none) Yes
Messaging app access No Yes
Web UI No (CLI + desktop app) Yes (self-hosted)
Provider-agnostic Yes (12+ providers) Yes
Self-hosted Yes Yes
Open source Yes (Apache 2.0) Yes
Background/cloud agent mode Yes (Codex Cloud) Yes (self-hosted cron)
Self-improving skills No Yes

vs. OpenCode

OpenCode is an open-source TUI agentic coding assistant supporting 75+ providers. It has a WebUI
embedded in its binary, an official desktop app, SQLite session history, and AGENTS.md project
context. It supports CLAUDE.md as a fallback for users migrating from Claude Code. There are 30+
community plugins, and community messaging integrations exist for Telegram, Slack, Discord, and
Microsoft Teams — though none are first-party and all require manual setup.

OpenCode Go ($10/month) and OpenCode Zen (curated model service) are subscription tiers. The
GitHub Copilot official integration launched January 2026. There is no native scheduling; a
community background plugin exists. No automatic cross-session semantic memory.

OpenCode Hermes
Persistent memory Partial (session history + AGENTS.md) Yes (automatic, layered)
Scheduled jobs No (community plugin only) Yes
Messaging app access Community integrations only (Telegram/Slack/Discord/Teams) Yes (first-party, many platforms)
Web UI Yes (embedded + desktop app) Yes (self-hosted)
Mobile access No Yes
Skills / plugins Yes (30+ community plugins) Yes (auto-generated, first-party)
Provider-agnostic Yes (75+ providers) Yes
Open source Yes Yes
Self-hosted autonomous execution No Yes

vs. Cursor

Cursor has changed substantially. The "no memory, no scheduling, no messaging" description was
accurate in 2024 and is wrong now.

Memories (per-project cross-session knowledge base) shipped in beta with v1.0 in June 2025.
Automations launched March 5, 2026 — time-based, event-based (GitHub/Linear/PagerDuty), and
communication-based (Slack) triggers that fire background agents on cloud VMs. The web app,
mobile agent, and Slack bot give it multi-surface reach. Cursor v3.0 (April 2, 2026) is
explicitly agent-first with Design Mode and 30+ marketplace plugins. Cursor acquired Supermaven
for autocomplete. As of early 2026 it's valued at $29.3B with $2B ARR. It is not a narrow editor
tool anymore.

Hermes still has a different profile: it's self-hosted and server-resident, the same persistent
identity follows you across every surface without cloud intermediation, and it works with any
model family rather than being cloud-VM-based. For workflows that require data sovereignty,
self-hosted scheduling, or deep Python/ML tooling on your own hardware, Cursor's cloud-agent
architecture is a fundamental mismatch. For teams that want editor-native agents with strong
IDE integration, Cursor's recent evolution is significant.

Cursor Windsurf Copilot Hermes
In-editor autocomplete Excellent (Supermaven) Excellent (Cascade) Excellent No
Inline diff / refactor Yes Yes Yes Via shell
Cross-session memory Yes (Memories, per-project) Yes (Cascade Memories, workspace) Yes (Agentic Memory, repo-scoped, 28-day expiry) Yes (automatic, persistent)
Scheduled background jobs Yes (Automations, cloud VM) No Via Coding Agent (issue-driven) Yes (self-hosted cron)
Messaging app / multi-surface Yes (Slack bot, web app, mobile) No Via Copilot CLI / fleet Yes (many platforms)
Background/cloud agent mode Yes (Automations on cloud VMs) No Yes (Coding Agent, GA Mar 2026) Yes (self-hosted)
Terminal tool use Limited Limited Limited Full
Self-hosted No No No Yes
Self-hosted autonomous execution No No No Yes
Provider-agnostic Partial Partial No (GitHub models) Yes
Open source No No No Yes
Memory inspectability Partial Yes (stored locally) Limited Yes (markdown files)

vs. Claude.ai and ChatGPT

These are no longer simple chat tools. The description of "no memory, no scheduling, no
messaging" is inaccurate for both.

Claude Cowork (in Claude Desktop) launched scheduled tasks on February 25, 2026 — hourly,
daily, weekly, weekdays, and on-demand. It runs in an isolated VM with file and shell access.
Claude has 50+ service connectors as of February 2026 including Slack (launched January 26,
2026), Gmail, Google Calendar, Google Drive, Microsoft 365, Notion, Asana, Linear, and Jira.
Memory auto-generates from chat history, not just user-curated entries. Code execution and
file access in Artifacts is sandboxed, not the same as shell access on your own server.

ChatGPT has Agent Mode (launched July 17, 2025), Scheduled Tasks (January 2025, recurring
automated prompts), a computer-using agent, Projects, 50+ connectors including Gmail, GitHub,
and Google Drive, dual-mode memory (auto + manual), and ChatGPT Pulse for Pro users (daily
research briefings). It is not a passive Q&A interface.

Where Claude.ai and ChatGPT differ from Hermes: neither is self-hosted, neither is
provider-agnostic, and neither gives you execution on your own hardware. Connectors and
scheduling exist, but they run on Anthropic's or OpenAI's infrastructure. Your memory, session
history, and agent execution live on their servers, not yours. For many use cases that's fine
— they are capable and well-supported. For privacy-conscious users, regulated environments, or
workflows that require persistent server-side execution on controlled hardware, it's a
disqualifying constraint.

Claude.ai ChatGPT Hermes
Memory across conversations Yes (auto-generated from history) Yes (dual-mode: auto + manual) Yes (deep, automatic)
Scheduled tasks Yes (Cowork: hourly/daily/weekly) Yes (since Jan 2025) Yes (any cron, self-hosted)
Service connectors / messaging Yes (50+ via Cowork) Yes (50+ connectors) Yes (many platforms, direct)
Runs shell commands Sandboxed (Cowork VM) Sandboxed Yes (full shell)
Code execution Sandboxed Sandboxed Yes (full shell)
Reads / writes files Sandboxed Sandboxed Yes (full filesystem)
Web UI Yes (Anthropic-hosted) Yes (OpenAI-hosted) Yes (self-hosted)
Self-hosted No No Yes
Provider-agnostic No No Yes
Open source No No Yes
Self-hosted autonomous execution No No Yes
Memory inspectability Limited Limited Yes (markdown files)

The compounding advantage

What distinguishes Hermes from most of the tools above is that it gets meaningfully better at
your specific workflow over time without manual configuration.

Every time Hermes encounters a new environment, it saves facts to memory. Every time it solves
a problem a new way, it saves the approach as a skill. Every time you correct it, it updates its
profile of you. Every session, every scheduled job, every tool call adds to a body of knowledge
that is specific to you, stored on your hardware, and available to every future interaction.

A Claude Code session on day one and day one hundred are identical — it starts fresh. A Hermes
agent on day one and day one hundred knows your stack, your conventions, your preferences, and
the solutions that have worked before. That's the actual compounding.


Who Hermes is for

Solo developers and power users who don't want to re-explain their stack every session and want
an AI that actually knows their environment.

Teams on a shared server where multiple people want capable AI access without each paying for
a separate subscription or running separate local tooling.

Automation-heavy workflows where you want an AI running tasks on a schedule, delivering results
to your phone, without babysitting it.

Privacy-conscious users who want their conversations, memory, and files on their own hardware.

Multi-model users who want to switch between OpenAI, Anthropic, Google, DeepSeek, and others
based on cost, capability, or rate limits, without rebuilding their workflow each time.


What Hermes is not

Hermes is not the best in-editor autocomplete tool. Cursor and Windsurf do that job better.
Use one alongside Hermes.

It is not zero-setup. You are running a server. That means initial configuration, and it means
you're responsible for uptime, upgrades, and backups. The tradeoff is data sovereignty and
control; that only makes sense if you actually want it.

It does not make weaker models magical. Memory and skills help, but the underlying model still
determines reasoning quality. Hermes with a weak model is a well-organized weak model.

It still needs guardrails, approvals, and observability for high-stakes automations. Autonomous
execution on a schedule with shell access is powerful and requires judgment about what to
approve. Terminal commands can require confirmation before running; use that for anything
consequential.

If you need the absolute lowest-friction path to a one-off answer or a quick edit, a chat
interface or an in-editor tool is the right call. Hermes is for continuity and autonomy, not
minimum-friction one-shots.


Scope and limits

Hermes lives in the terminal, browser, and messaging apps. For in-editor autocomplete and inline
diffs, use Cursor or Windsurf — they do that job better and work well alongside Hermes.

You run Hermes on your own server. That means initial setup, but your data stays on your
hardware and you control the schedule, the models, and the costs.

Hermes is an orchestration and memory layer. It makes whatever model you point at it more useful
over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
something durable.


Security and control

Memory is stored locally on your server as readable, editable files: user profile, agent memory,
and skills are all markdown. Session history is in SQLite on your machine. You can inspect,
edit, or delete any of it directly.

If you want external memory providers, eight are supported: Mem0, Honcho, Hindsight, RetainDB,
ByteRover, Supermemory, Holographic, and others. These are optional and configurable.

Execution runs in configurable backends: local shell, Docker, SSH, Daytona, Singularity, or
Modal. You choose what execution environment Hermes operates in and what it can reach.

Terminal commands can require confirmation before running. For any automation that touches
production systems or makes external calls, enable approval controls.

Secrets stay on your hardware. Hermes does not phone home; it calls whatever model APIs you
configure directly.

Multiple profiles give isolation between users or projects. A shared server can have separate
profiles with separate memory, separate skills, and separate history.


Quick reference

OpenClaw Claude Code Codex OpenCode Cursor Copilot Claude.ai ChatGPT Hermes
Persistent memory (auto) Yes Partial† Partial Partial Yes (per-project) Yes (repo-scoped‡) Yes Yes Yes
Scheduled / background jobs Yes Partial§ Partial¶ No Yes (Automations) Via Coding Agent Yes (Cowork) Yes Yes (self-hosted)
Messaging / multi-surface Yes (24+ platforms) Partial (preview) No Community only Yes (Slack/web/mobile) Via CLI/fleet Yes (50+ connectors) Yes (50+ connectors) Yes (many platforms)
Web UI Chat UI + control dashboard Anthropic-hosted No Yes Yes + mobile github.com Yes (Claude Desktop) Yes Yes (self-hosted)
Skills system Yes (ClawHub marketplace) Yes (Hooks + Plugins) Partial (Skills) Community plugins Yes (marketplace) No No No Yes (auto-generated)
Self-improving skills Partial No No No No No No No Yes
Browser / computer control Yes (Chrome CDP) No No No No No No Yes (CUA) Via shell
In-editor autocomplete No No Via extension No Excellent Excellent No No No
Orchestrates other agents No No No No No No No No Yes
Provider-agnostic Yes No (Claude only) Yes Yes Partial No No No Yes
Self-hosted Yes No Yes (CLI) Yes No No No No Yes
Self-hosted autonomous execution Yes No No No No No No No Yes
Background/cloud agent mode Yes Yes (cloud) Yes (Codex Cloud) No Yes (cloud VMs) Yes (Coding Agent) Yes (Cowork VM) Yes (Agent Mode) Yes (self-hosted)
Memory inspectability Limited Partial Partial Partial Partial Limited Limited Limited Yes (markdown files)
Open source Yes (MIT) No Yes (Apache 2.0) Yes No No No No Yes
Always-on autonomous execution Yes No No No No No No No Yes

† Claude Code: CLAUDE.md / MEMORY.md project context plus auto-memory since v2.1.59+; no automatic cross-project accumulation
‡ Copilot Agentic Memory: public preview Jan 15, 2026; enabled by default Mar 4, 2026; repo-scoped, auto-expires after 28 days
§ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
¶ Codex scheduling: desktop app Automations only; CLI has no native scheduling


Workspace Git (`docs/workspace-git.md`)

Workspace Git controls

Workspace Git controls let the browser inspect Git state for the active session workspace. By default,
WebUI can read status, list branches, show diffs, fetch remote refs, and generate commit-message
suggestions. Actions that modify the repository, index, or worktree are disabled unless the WebUI
process is started with HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1.

Trust model - read this first. Once mutating Git actions are enabled, a browser action can run
Git commands inside a mounted workspace. Some Git commands can also run repository hook code from
.git/hooks/ or a configured core.hooksPath. That hook code runs as the WebUI process user, with
the WebUI process permissions and environment. Treat hooks that download or execute code as code
execution by the WebUI process user.

What works by default

Without HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1, WebUI can:

  • show repository status
  • list branches
  • show file diffs
  • fetch from the configured remote
  • generate commit-message suggestions

Fetch may update remote-tracking refs, but it does not change the worktree, merge branches, create
commits, or push changes.

Commit-message generation may send staged or selected diff context to the configured model provider.
The UI labels this before generation.

Diffs for untracked files are size checked before WebUI reads file contents. Large or binary files
return metadata instead of inline diff text.

What requires explicit enablement

These actions are blocked unless HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1 is set:

  • stage and unstage
  • discard changes
  • commit and selected-file commit
  • pull and push
  • checkout
  • branch switching that parks and restores local changes

Leave the flag unset for deployments where WebUI should only inspect mounted workspaces. Set it only
when browser users are trusted to modify those repositories from WebUI.

When the flag is enabled, branch switching parks and restores local changes automatically. If the
branch being left has local changes, WebUI parks those changes in a WebUI-owned Git stash, switches
branches, and then restores any WebUI-owned stash for the branch being entered. If Git cannot restore
the stash cleanly, WebUI leaves the stash in place and reports the restore failure instead of dropping
it.

Workspace and path scope

The browser does not send an arbitrary repository path. Git requests carry a session id and, when
needed, workspace-relative file paths. The server resolves the session workspace, checks each path
against that workspace, and then builds Git pathspecs from the checked paths.

Git commands run through subprocess.run with shell=False. Local status and diff commands use a 5
second timeout. Remote operations such as fetch, pull, and push use a 60 second timeout.

Before any Git subprocess starts, WebUI removes inherited GIT_DIR, GIT_WORK_TREE,
GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, GIT_CONFIG_COUNT, GIT_CONFIG_PARAMETERS, and injected
GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* values from the environment. It also removes inherited
GIT_ASKPASS, SSH_ASKPASS, GIT_SSH, and GIT_SSH_COMMAND values, then sets
GIT_TERMINAL_PROMPT=0 to disable Git's terminal authentication fallback.
Those variables can redirect Git to a different repository, inject config, or run
helper commands, so WebUI does not trust them from the parent process.

Remote fetch, pull, push, and ls-remote also receive GCM_INTERACTIVE=never and
credential.interactive=false. GCM honors these controls, and Git itself honors
credential.interactive starting in 2.47. Cached credentials from trusted
system/global helpers remain usable. On a cache miss, Git 2.47+ may report
unable to get password from user rather than could not read Username; both
map to the workspace API's auth_failed code. Other credential helpers may
ignore these controls and still open their own browser or GUI; this is not a
universal guarantee that every helper is noninteractive.

GIT_INDEX_FILE is the intentional exception. Selected-file commits use a temporary index so WebUI
can commit only the requested files, then remove the temporary index afterward.

Coordination with active runs

Mutating Git actions are rejected while the same session has a live stream. The API returns
active_stream instead of racing a running agent that may be writing files in the same workspace.

Mutating Git actions also take a per-repository lock. If another Git mutation is already running for
that repository, the API returns operation_in_progress.

Hook and remote behavior

WebUI does not bypass Git hooks. Hook code may come from .git/hooks/ or from a configured
core.hooksPath.

Commit actions run normal commit hooks such as pre-commit, commit-msg, and post-commit. Push can
run pre-push. Pull uses --ff-only, so it does not create a merge commit, but it is still a Git
operation running under the WebUI process.

Push keeps Git's normal non-fast-forward protection and reports non-fast-forward rejection separately
from general Git failures.

If a hook fails, the API returns a structured Git error instead of hiding the failure. Other classified
failures include authentication errors, missing upstream branches, conflicts, dirty worktrees, invalid
refs, missing Git binaries, and timeouts.

Repository-local credential helpers, askpass commands, and SSH commands are disabled for workspace
Git operations. Generic and URL-scoped credential helpers from user and system Git config remain
available for private HTTPS remotes. SSH remotes can use the inherited SSH agent and a user/system
core.sshCommand; WebUI appends the SSH variant's batch option so unattended operations do not open password,
passphrase, or host-key prompts. Custom-named commands are recognized with a bounded -G
configuration probe; failed probes and Git's simple variant fail closed. Explicit interactive
BatchMode options are rejected because OpenSSH keeps the first value.

Credential helpers, core.sshCommand, and ssh.variant must be declared directly in the
primary system/global Git config. Includes are not followed for these settings, because an
included file can be checkout-controlled despite appearing to have global scope. Move included
authentication settings to the main user/system config. Explicit scope reads preserve
compatibility with Git versions before 2.26.


Wsl Autostart (`docs/wsl-autostart.md`)

Windows / WSL auto-start

Hermes WebUI runs well under WSL2, but native Windows login does not automatically start Linux user processes. This guide covers two supported options:

  1. WSL session startup — simple and low-risk. WebUI starts the next time you open a WSL shell.
  2. Windows Task Scheduler — true Windows logon startup. Windows invokes wsl.exe, which runs the WSL launch script.

Both paths use the same WSL launch script:

text
scripts/wsl/hermes_webui_autostart.sh

The script is safe to call repeatedly. It uses a lock file, checks the /health endpoint, checks a pid file, and writes logs before starting start.sh --foreground in the background. It does not hardcode a user path; by default it derives the repository root from its own location.

Script settings

The WSL launcher supports these environment variables:

Variable Default Purpose
HERMES_WEBUI_REPO repo containing the script WebUI checkout to start
HERMES_WEBUI_LOG_DIR $HOME/.hermes/webui/logs Autostart and WebUI logs
HERMES_WEBUI_HOST 127.0.0.1 Host passed through to start.sh / bootstrap.py
HERMES_WEBUI_PORT 8787 WebUI port and health-check port
HERMES_WEBUI_HEALTH_URL http://127.0.0.1:$HERMES_WEBUI_PORT/health URL used to decide whether WebUI is already running
HERMES_WEBUI_PID_FILE $HERMES_WEBUI_LOG_DIR/hermes-webui.pid pid file used for duplicate prevention
HERMES_WEBUI_REQUIRE_AGENT_PROCESS 0 Optional: set to 1 only if your local setup requires a separate Hermes process before WebUI starts

Make the script executable once inside WSL:

bash
cd /path/to/hermes-webui
chmod +x scripts/wsl/hermes_webui_autostart.sh

Run it manually to verify your paths and logs:

bash
scripts/wsl/hermes_webui_autostart.sh
curl -fsS http://127.0.0.1:8787/health

Logs are written to:

text
$HOME/.hermes/webui/logs/webui_autostart.log
$HOME/.hermes/webui/logs/hermes_webui.log

Option 1: WSL session startup

This starts WebUI when your WSL login shell starts. It is the easiest option if you already open WSL during your day.

Add this to ~/.profile or ~/.bashrc inside WSL, adjusting the repo path:

bash
if [ -x "$HOME/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" ]; then
  HERMES_WEBUI_REPO="$HOME/hermes-webui" \
    "$HOME/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" >/dev/null 2>&1 &
fi

Open a new WSL terminal and check:

bash
curl -fsS http://127.0.0.1:8787/health

If you open several WSL terminals, the launcher should still start only one WebUI process because the lock, health check, and pid file all converge on "already running".

Option 2: Windows Task Scheduler startup

Use this if you want WebUI to start automatically at Windows logon even before you open a WSL terminal.

The helper PowerShell script is:

text
scripts/windows/setup_webui_autostart.ps1

From Windows PowerShell, run it with the WSL path to the launch script:

powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\scripts\windows\setup_webui_autostart.ps1 `
  -WslScriptPath "/home/your-user/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" `
  -Distro "Ubuntu"

Notes:

  • -Distro is optional. Omit it to use your default WSL distro.
  • The default task name is HermesWebUIAutoStart; pass -TaskName if you need a different name.
  • The script is idempotent: rerunning it updates the existing scheduled task instead of creating duplicates.
  • The task runs as the current Windows user at logon with least privilege.
  • Add -WhatIf to preview the scheduled task registration.
  • Add -RunNow to start the task immediately after registration.
  • Add -SkipValidation only if you need to register the task before the WSL path exists.

To inspect or remove the task later:

powershell
Get-ScheduledTask -TaskName HermesWebUIAutoStart
Unregister-ScheduledTask -TaskName HermesWebUIAutoStart -Confirm:$false

Troubleshooting

Check the WSL logs first:

bash
tail -n 80 "$HOME/.hermes/webui/logs/webui_autostart.log"
tail -n 80 "$HOME/.hermes/webui/logs/hermes_webui.log"

Common causes:

Symptom Likely cause Fix
Task exists but WebUI is not reachable WSL script path is wrong for the selected distro Re-run the PowerShell setup with the correct -WslScriptPath and -Distro
WebUI starts only after opening WSL You used the WSL session startup option, not Task Scheduler Install the Windows scheduled task
Multiple login events happen quickly Normal Windows startup behavior The WSL script should log already running and avoid duplicate processes
Health check fails but pid exists WebUI is still booting or the port differs Check HERMES_WEBUI_PORT and hermes_webui.log

If you want WSL2 systemd integration instead, see docs/supervisor.md for foreground process-supervisor guidance and adapt the Linux systemd --user pattern to your distro.


Architecture Agent Api Contract (`docs/architecture/agent-api-contract.md`)

WebUI to hermes-agent source dependency contract

This document records the current WebUI dependency on the hermes-agent source
tree for issue #2491. It is an audit and replacement contract only; it does not
change runtime behavior or Docker wiring.

Run the deterministic audit with:

powershell
python scripts/audit_agent_source_dependencies.py
python scripts/audit_agent_source_dependencies.py --format markdown

The JSON output is repo-relative and grouped by stable dependency class IDs so
follow-up PRs can remove one class at a time without relying on exact line
fixtures.

Current boundary

The multi-container setup still shares the agent source tree with the WebUI:

  • docker-compose.two-container.yml mounts hermes-agent-src at /opt/hermes
    in the agent service and read-only at
    /home/hermeswebui/.hermes/hermes-agent in the WebUI service.
  • docker-compose.three-container.yml uses the same source volume pattern.
  • docker_init.bash documents that the WebUI-side mount is read-only and uses
    a staged copy when installing from the source checkout.
  • docs/docker.md explicitly documents that this is not a filesystem trust
    boundary: the WebUI cannot write the mount, but it still installs and imports
    code from it.

The durable target is that multi-container WebUI should not need a direct
hermes-agent-src mount. The WebUI should communicate with hermes-agent through
HTTP endpoints and a small versioned client/shared-schema package where pure
helpers are genuinely shared code.

Dependency classes

Audit class Current surface Replacement classification
docker_agent_source_volume Compose files and Docker docs expose hermes-agent-src and /opt/hermes to make the agent checkout visible to WebUI. Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR.
startup_dependency_install api/startup.py discovers HERMES_WEBUI_AGENT_DIR or $HERMES_HOME/hermes-agent; server.py calls auto_install_agent_deps() after import verification fails; docker_init.bash installs from the staged agent source. Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep HERMES_WEBUI_AGENT_DIR during migration as an override/debug path, but it should stop being required in normal multi-container startup.
runtime_auxiliary_model_metadata api/streaming.py, api/routes.py, api/config.py, and api/providers.py import agent.auxiliary_client, agent.model_metadata, agent.models_dev, hermes_cli.models, and agent.account_usage. Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage.
runtime_session_state api/streaming.py, api/goals.py, and api/state_sync.py import hermes_state.SessionDB directly. api/models.py also reads the messages table directly (see state.db message content encoding for the storage-format coupling that creates) and opens the active profile's canonical state.db for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container.
runtime_gateway_provider api/streaming.py and api/routes.py import hermes_cli.runtime_provider; adapter helpers such as agent.anthropic_adapter are also imported for gateway normalization. Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout.
webui_local_or_client_package WebUI imports hermes_cli.auth, hermes_cli.config, hermes_cli.plugins, hermes_cli.profiles, hermes_cli.goals, agent.skill_utils, agent.credential_pool, and hermes_constants. Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI.

state.db message content encoding

api/models.py reads the agent's messages table with its own SQL, so it also
depends on how hermes-agent encodes that table, not only on its schema. This
is a storage-format coupling and belongs with the runtime_session_state
dependency class above.

hermes_state stores list/dict message content (multimodal parts) as a
sentinel-prefixed JSON string, because sqlite3 binds only scalars:

text
_CONTENT_JSON_PREFIX = "\x00json:"        # hermes_state.py

It provides _decode_content() to reverse this. Any WebUI read path that
projects that column must apply an equivalent decode; a raw read hands the
frontend an encoded string that no reader recognises, and an image part's
base64 data URI then renders as literal transcript text.

WebUI decoding contract

_decode_state_db_content() in api/models.py is the single decode point. It
is deliberately narrower than the agent's own decoder, because the WebUI can
only accept shapes the rest of its pipeline already renders:

Input Result Why
Sentinel + list with non-whitespace text and only valid image parts decoded list msgContent() joins the text parts, so the row renders its text
Sentinel + image-only list, or text that is empty/whitespace unchanged string msgContent() discards image parts, so _messageIsRenderable() would hide the row with no error
Sentinel + list containing a malformed image part unchanged string a part must carry a valid per-type payload, not just a matching type
Sentinel + dict or scalar root unchanged string a dict reaches _getCachedRender(), and _renderCacheKey() calls text.slice() on it, blanking the turn
Sentinel + NaN/Infinity/overflowed float unchanged string Python emits them, browser JSON.parse() rejects the whole /api/session payload
Sentinel + unsupported part shapes unchanged string input_text, output_text, scalar and unknown parts are dropped by the JS readers, so decoding them would silently lose content that is visible today
Anything without the sentinel unchanged non-sentinel content is not this contract's concern

Supported parts are {"type": "text", "text": <str>} plus image parts whose
payload validates for their type: image_url with a non-empty URL (string or
{"url": ...}), input_image with a URL or file_id, and image with a
base64 source carrying data and media_type or a url source. At least one
text part must contain non-whitespace text.

Image parts do not render from this projection. The shared JS readers drop
them, and the state.db projection supplies no attachments. Decoding a
text-and-image row shows its text and keeps the base64 payload out of the DOM;
it does not display the image. Rendering images from state.db rows would need a
shared inline-image projection first, at which point image-only lists could be
accepted too.

Widening the accepted schema requires teaching every shared content reader
through one extractor first; until then unsupported shapes must keep falling
back to the raw string.

Consequences for identity and bounded reads

Decoding changes the runtime type of content, so every consumer that derives
an identity from it must agree on one representation:

  • Every key -- merge, dedup, content, visible and the fuzzy fallback -- derives
    content identity through _content_identity_for_key(). Non-list values key
    exactly as on master, str(content or ""). Non-empty lists get an
    out-of-band tuple identity, so no message body can compare equal to one:
    an in-band string marker would be forgeable by a scalar that contains it.
    Two rich turns sharing visible text and timestamp stay distinct when their
    images differ.

  • Fuzzy duplicate matching is text-only. Structured identities match by exact
    identity or not at all, so a rich row can never fuzzy-match a scalar.

  • The merge key cache never writes a key component back into message content.
    To avoid re-serialising large payloads for every key it instead memoises the
    canonical serialisation per content object, scoped to one
    merge_session_messages_append_only() call.

  • The multimodal mirror bridge pairs one rich image-bearing row with one scalar
    mirror only. require_image_parts and require_scalar_mirror are mutually
    exclusive so rich-to-rich pairing cannot occur.

  • Every read path that projects the content column applies the decoder, so
    keys derived on one path cannot disagree with keys derived on another. There
    are exactly three such call sites:

    Call site Role
    _project_state_db_message() canonical row projection, shared by the transcript read and the regeneration tail
    get_state_db_session_message_keys_before_timestamp() bounded prefix keys
    get_state_db_regeneration_tail_snapshot() regeneration prefix keys

    If prefix keys stayed encoded while the projected tail was decoded, the
    prefix/tail collision proof could miss a genuine repeated recovered turn and
    _bounded_tail_snapshot_if_safe would reject the bounded path, reading the
    entire transcript during regeneration.

    Two nearby paths deliberately need no decode.
    get_state_db_session_message_prefix_summary() projects only timestamp
    counts and never selects content. State-db sidecar reconstruction
    (_sync_sidecar_from_state_db_if_newer()) sources its rows through
    get_state_db_session_messages(), so it inherits the canonical decoded
    projection rather than reading the column itself.

When session state moves behind hermes-agent endpoints, this decode should move
with it: the agent should return structured content over the API and the WebUI
should stop depending on the sentinel format at all.

Replacement contract

Existing endpoint candidates

The WebUI already exposes provider, model, profile, route, and streaming
handlers that callers use today. Those handlers can remain as WebUI HTTP routes
when they only format UI responses, but their implementations should stop
loading agent modules directly. Good candidates for reusing the current WebUI
route shape while changing its backend dependency are:

  • Provider/model catalog routes currently backed by hermes_cli.models.
  • Auxiliary title/compression paths currently backed by agent.auxiliary_client.
  • Context-window and token-estimate paths currently backed by
    agent.model_metadata.
  • Runtime-provider choices currently backed by hermes_cli.runtime_provider.

New hermes-agent endpoints needed

These surfaces require an agent-owned endpoint because they read agent state,
perform provider/runtime decisions, or expose privileged data:

  • SessionDB/session state read and write operations now using
    hermes_state.SessionDB, plus the scoped direct-SQL session deletion in
    api/models.py. The replacement endpoint must preserve branch/compression
    precedence and expose retryable post-commit artifact cleanup before that
    compatibility path can be removed.
  • Runtime provider resolution and gateway normalization now using
    hermes_cli.runtime_provider and agent.anthropic_adapter.
  • Auxiliary task execution and configuration now using agent.auxiliary_client.
  • Credential/auth/account usage access now using agent.credential_pool,
    hermes_cli.auth, and agent.account_usage.
  • Profile, plugin, goal, and skill operations that mutate or discover
    agent-owned resources.

Client/shared package candidates

These items can be kept out of the live agent API if they are pure, versioned,
and safe to import without the agent source tree:

  • Shared constants currently imported from hermes_constants.
  • Provider/model schema names and non-privileged catalog shape definitions.
  • Pure skill/profile parsing helpers that do not touch agent-owned state.
  • Typed response/request models for the new hermes-agent endpoints.

WebUI-local items

The WebUI can keep code that is only presentation, validation, or routing glue:

  • User-facing diagnostics that display whether HERMES_WEBUI_AGENT_DIR is set.
  • Route-level request validation and response formatting.
  • WebUI-only caches and client-facing state that do not open agent SessionDB.
  • Docker documentation describing the transition while both paths are supported.

Audit expectations

tests/test_agent_source_dependency_audit.py pins the contract shape:

  • Docker/compose source sharing is reported.
  • Startup dependency installation and HERMES_WEBUI_AGENT_DIR are reported.
  • Runtime auxiliary/model metadata imports are reported.
  • Runtime SessionDB/state imports are reported.
  • Runtime provider/gateway imports are reported.
  • The catch-all class for local/client-package candidates remains populated.

The tests intentionally check stable class IDs and representative anchors, not
exact full fixtures. Follow-up migration PRs should update this document and the
audit expectations when a dependency class is intentionally reduced or removed.


Architecture Live Models Allowlist (`docs/architecture/live-models-allowlist.md`)

Live model picker allowlist contract (`/api/models/live`)

This document records the runtime contract for how /api/models/live decides
which models a custom / custom:* provider contributes to the chat model
picker. It describes shipped behavior and changes no runtime behavior. It was
added because the #7165 review flagged the contract as undocumented: the
filtering rules live in _handle_live_models (api/routes.py) and had only
code comments backing them.

Start here before changing how the live catalog of a custom provider is
filtered, how discovery snapshots are distinguished from allowlists, or what
the endpoint returns when the upstream probe fails.

Why the endpoint filters at all

OpenAI-compatible gateways (New-API-style aggregators, LM Studio, Ollama
proxies) expose their entire catalog from /v1/models, including
image-generation, audio, TTS, and embedding models that are not chat models.
The background enrichment path must not surface models the user never opted
into — but it also must not hide models the gateway gained since config was
last edited. The contract below is how those two goals are reconciled.

The four signals, in evaluation order

For a custom provider the handler consults these signals:

  1. Discovered catalog → no allowlist (live probe wins).
    When the provider entry carries models_discovered: true and discovery is
    allowed (discover_models is not an explicit opt-out), the models:
    mapping is a snapshot Hermes persisted from a past discovery, not a
    hand-curated list. It must not gate the live probe: a model the user pulls
    into LM Studio / Ollama after discovery has to appear in the picker.
    Detected by the shared predicate
    api.config._provider_models_are_discovered_catalog() — the same
    predicate the /api/models catalog path uses, so both endpoints agree.
    With this signal, no allowlist is derived and the full live catalog is
    returned.

  2. Explicit plural models: allowlist → filter.
    A non-empty plural models: value on an entry that is not a discovered
    catalog expresses "show exactly these models". The live catalog is
    intersected with it, then any allowlisted model missing from the live
    response is appended (so an allowlisted-but-offline model stays
    selectable). This is the #7165 filter. Only list and string
    (serialized) shapes are plain allowlists — see "Dict-shaped models:"
    below.

  3. Singular model: → never gates.
    The singular field is sticky/default metadata (the model to preselect),
    not an allowlist. A provider configured only with model: assistant keeps
    the full unfiltered live catalog; gating on it would collapse discovery to
    a single model.

  4. No allowlist → full live catalog.
    Preserves the pre-#7165 discovery behavior for setups that rely on it.

Serialized shapes

hermes config set and the JSON-mode editor persist lists as quoted
JSON-array strings ('["chat-a","chat-b"]') or Python literals
("['chat-a']"). The plural value is decoded through the shared
_parse_config_string_list() before id extraction — but only when the value
is a str; native dict / list values are walked directly so dict entries
keep their id|model|name metadata (the decoder stringifies members). A
dict-only parser drops serialized allowlists to "no allowlist" and floods the
picker — the same bug class as skills.disabled (#7120 / #7134).

Dict-shaped `models:`

A dict-shaped models: mapping (e.g. {chat-a: {context_length: 128000}})
is per-model metadata written by the Hermes Agent setup flow —
hermes_cli/model_switch.py::_save_custom_provider and the setup wizard —
not a catalog narrow. Treating its keys as an allowlist would collapse the
live picker to the single saved default (keyless Ollama) while the CLI
live-probe shows the full catalog. So a dict is never a plain allowlist:

  • without an explicit discover_models: false, a dict-shaped models:
    contributes no allowlist and the full live catalog is returned;
  • with discover_models: false (bool False or the string forms
    "false"/"no"/"0", case-insensitive — mirroring
    model_switch_providers.py:557), the dict keys are honored as a pinned
    allowlist.

This complements the discovered-catalog rule: models_discovered: true
without the opt-out is already ignored (signal 1). The dict guard covers the
Agent-setup shape that is not flagged discovered.

If someone later "fixes" the dict shape back into an always-allowlist, the
regression test test_dict_models_without_discover_false_does_not_gate fails
and forces the trade-off to be re-argued.

Empty allowlist semantics

models: [], models: "[]", an empty mapping, or a blank scalar are
deliberately treated as not configured, never as "allow nothing". The
filter runs only on a non-empty allowlist because gating on declared-ness
would emit an empty picker and make the provider unselectable — a harder
failure than showing a few extra models, and unrecoverable from the UI
(custom_providers has no WebUI write path; it is hand-edited in
config.yaml). There is no deny-all use case: a provider the user wants hidden
is removed from custom_providers outright.

Probe failure / empty probe

When the upstream probe fails or returns no models, the endpoint falls back
to allowlist or config ids — the non-empty plural allowlist first, so a
singular model: outside the allowlist cannot leak in precisely when the
probe is unavailable; with no allowlist, the configured ids (singular +
plural) are served. A discovered catalog therefore still serves its saved
models on a transient probe failure instead of emptying the picker.

Tests

  • tests/test_issue3718_live_models_custom_probe.py — real-handler
    (_handle_live_models) coverage of every signal above: plural list /
    JSON-array-string / Python-literal / list-of-dicts filtering, dict-shaped
    models: (not gated) and dict + discover_models: false (pinned),
    singular passthrough, malformed safety, discovered-catalog passthrough,
    discover_models: false pinning, and both fallback branches.
  • tests/test_issue7404_models_discovered_not_allowlist.py — the
    /api/models side of the discovered-catalog contract.
  • tests/test_byok_model_dropdown.py — picker rendering, profile-scoped
    caching, and current-session model preservation around this endpoint.

Architecture Models Cache Invalidation (`docs/architecture/models-cache-invalidation.md`)

Models catalog cache invalidation contract (`/api/models`)

This document records the current identity and invalidation contract for the
/api/models catalog cache in api/config.py. It describes shipped behavior and
changes no runtime behavior. It was added after #7556 shipped in exp-v0.52.303,
which the #7556 review flagged as an undocumented runtime contract.

What is cached

  • In memory: _available_models_cache plus _available_models_cache_ts,
    with _AVAILABLE_MODELS_CACHE_TTL set to 24 hours.
  • On disk: one models_cache.json per profile under the WebUI state
    directory (_get_models_cache_path()), stamped with _schema_version and
    _webui_version.
  • Cold path: get_available_models(prefer_cache=...); the prefer_cache
    branch never starts a live provider rebuild.
  • Hot path: _endpoint_advertised_model_ids() reads only the published
    in-memory snapshot through the lock-free _models_cache_provenance tuple and
    validates it against the current source fingerprint before trusting it.
    _sync_models_cache_provenance() must run at every site that publishes or
    invalidates the snapshot, so the tuple can never tear.

The five source axes

_models_cache_source_fingerprint() is the single chokepoint. A cache is served
only when every axis matches the value recorded in the cache — the disk reader
compares it in _is_loadable_disk_cache(), and the hot path compares it against
the fingerprint captured at publish time.

Axis Identity Why it is fingerprinted this way
config_yaml stat identity: mtime_ns + size (_models_cache_file_fingerprint) The file is rewritten only on deliberate user edits, and any edit can change the provider/model set, so the cheap conservative identity wins.
auth_json content hash with a volatile-key deny-list (_auth_store_semantic_fingerprint, _AUTH_FINGERPRINT_VOLATILE_KEYS) The credential store is rewritten roughly every 14 minutes by credential-pool / OAuth refresh; none of those rotating fields feed detected_providers or the returned catalog, and stat identity made the 24h cache churn on every refresh (RCA t_d127953d / t_16551f61).
env [key, HMAC-SHA256(signing key, value)] per non-empty .env entry, parsed by providers._load_env_file (_models_cache_env_fingerprint); plaintext values are never recorded Env keys decide detected_providers and values such as LM_BASE_URL decide which endpoint is probed, so both key and value changes invalidate.
plugins [relpath, mtime_ns, size] of every non-bytecode file of each model-provider plugin, discovered like providers._scan_home_layer, flat manifests parsed with PyYAML like the agent (_models_cache_plugin_fingerprint) The loader execs plugin code that builds fallback_models, so any file edit must invalidate, even without a version bump.
catalog baked-in provider catalog sha256 (_PROVIDER_MODELS + _PROVIDER_DISPLAY) plus the Codex local catalog (_codex_models_cache_fingerprint, _CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS) A restart after a catalog change must not keep serving a persisted payload for up to 24h (#2443). Codex rewrites ~/.codex/models_cache.json on its own timer, bumping mtime_ns and size while models, etag, and client_version stay identical, so the Codex axis hashes content with only the refresh timestamps (fetched_at, updated_at) removed (#7540, #7556).

The over-budget stale fallback (_load_stale_models_cache_from_disk) tolerates a stale
_webui_version but never a source-fingerprint mismatch: such a snapshot is a wrong catalog.

Codex catalog and routing

The configured default is checked against the active provider's own group
(including its overflow entries), not against matching bare IDs in other
providers' groups. An OpenAI API entry therefore cannot suppress insertion of a
configured Codex default. The normal provider-qualified deduplication still
keeps the resulting picker options distinct.

The static openai-codex model list is a degraded fallback, not an account
entitlement list. Account-aware live discovery and visible entries in the local
Codex catalog can add models absent from that fallback. Generic Agent-core
seeding skips openai-codex so it cannot reintroduce retired or account-specific
IDs into the static list; it still enriches other providers.

Codex selections are qualified as @openai-codex:<model> before the
same-provider bare-ID shortcut. This keeps a live-discovered Codex model routed
to Codex even if another configured provider advertises the same ID. The
separate OpenAI API catalog does not determine Codex subscription availability.

Invariant: deny-lists are one-directional

  • Both volatile-key sets are deny-lists, never allow-lists. They may remove only
    fields that provably do not gate the provider/model set.
  • Every other field — including fields that Codex or the auth store may add in
    the future — stays in the fingerprint
    (test_unknown_codex_field_stays_in_fingerprint).
  • Consequence: excluding a volatile key can only make the fingerprint more
    stable
    ; it can never hide a genuine catalog or provider change. When in
    doubt, keep the key in.

Invariant: fallbacks are never less safe than stat

  • Missing file → recorded as missing, and the fingerprint stays stable
    (test_missing_codex_cache_fingerprint_is_stable_and_marked).
  • Unreadable, corrupt, or mid-write JSON → stat identity, marked
    unparsed-fallback.
  • Transform failure — including RecursionError on a pathologically deep tree →
    stat identity, marked encode-fallback
    (test_deeply_nested_codex_cache_degrades_to_stat_fallback_without_crashing).
    The fingerprint must never raise into /api/models; a real rewrite still
    changes the stat identity, so the fallback is strictly no less safe than the
    pre-#7556 behavior.

Version stamps (independent of the fingerprint)

  • _schema_version must equal _MODELS_CACHE_SCHEMA_VERSION (currently 3).
    Bump it when the cached payload shape changes incompatibly.
  • _webui_version must equal the running version, which forces a rebuild after
    every release so picker-shape fixes appear immediately instead of after the
    TTL expires. When the runtime version cannot be resolved (early boot), that
    check is skipped rather than wedging the boot.
  • _built_at records the POSIX timestamp (in seconds) of when the disk cache
    was built by _save_models_cache_to_disk. Freshness checks evaluate age
    against now - _built_at rather than filesystem st_mtime to prevent read/access
    touches from resetting the cache age while preserving fallback to st_mtime
    if the stamp is missing or invalid.

Invalidation paths: memory vs. disk

Path In-memory snapshot Disk snapshot
invalidate_models_cache() (default delete_disk=True) dropped deleted
invalidate_models_cache(delete_disk=False) (POST /api/profile/switch) dropped kept
invalidate_provider_models_cache(provider_id) dropped deleted
_get_fresh_memory_models_cache() on fingerprint mismatch / invalid shape dropped untouched
config-reload branch in get_available_models() dropped deleted by _refresh_config_cache() only when the same config.yaml path was already loaded and changed; a first load or path change (per-client switch) keeps it

The switch keeps the disk snapshot because it is keyed per profile and
_is_loadable_disk_cache() rejects it unless every axis above matches, so any
new catalog input must become an axis first. delete_profile_api() and
create_profile_api() unlink models_cache.<name>.json, so a recreated profile
never inherits the old catalog.

Credential eviction is part of the same epoch-reset critical section as memory
invalidation, before rebuild admission reopens. Full invalidation clears all
profile pools; provider invalidation removes the original and canonical provider
keys for the active profile. Both retain disk-commit → catalog lock order.

Custom endpoint scheduling is work-conserving: a fair-share slice bounds the
serial wait, while its HTTP attempt can continue against the same caller deadline
(with publication headroom and the endpoint cap). Later endpoints still get
in-band attempts; unused time after them is lent back to pending probes. Completed
outcomes are consumed by the catalog worker and merged through its rebuild-local
memo before returning, so provider order does not hide a healthy slow endpoint.
HTTP threads receive already-resolved requests, own only private outcome boxes,
and never touch profile state, catalog/durable caches or publication locks.

A custom-endpoint timeout below the full endpoint cap is a truncated attempt,
not evidence of unreachability. A partial catalog with groups can be returned but
is not published to either cache; an empty partial uses the existing stale-disk
or static fallback instead. The existing worker checks generation revocation
under the catalog condition after the foreground handoff, then retries truncated
targets at the full cap and publishes only through the generation/source/ownership
fences. Unrelated live provider lookups (including empty results and exceptions)
are memoized only for that rebuild, not shared with a successor or another profile.
An abandoned partial is removed from the publication box before revocation is
checked. Empty partials that finish in time do not log a budget-overrun warning.
Malformed LM Studio responses whose data is not a list are ignored, preserving
the degraded catalog behavior for both models and onboarding callers.

Change protocol

  1. Add or change a source axis in _models_cache_source_fingerprint() only —
    one chokepoint, so the disk reader and the published-snapshot reader always
    agree.
  2. When adding a volatile key, name the value it derives from and prove it
    inert: one test that fails if the key is dropped from the deny-list, and one
    that shows a genuine catalog change still invalidates the cache.
  3. Keep the fingerprint cheap, deterministic, and safe to run synchronously —
    it is recomputed on cache reads, including a per-turn hot path.
  4. Do not add a second, parallel cache-identity mechanism. The stat-based
    _models_cache_file_fingerprint() stays only as the conservative fallback for
    the axes above.
  5. This is a runtime contract: changes here update this document and are
    described in the PR body. Release-note wording belongs in the PR body, not in
    CHANGELOG.md, which release commits own.

Tests

tests/test_issue7540_codex_catalog_fingerprint.py covers both invariant groups:
timestamp-only churn keeps the fingerprint identical (and a session visit after a
Codex refresh needs no live rebuild), while genuine changes — a new model, a
visibility change, any catalog field, any unknown field — still invalidate.

tests/test_profile_switch_models_disk_cache.py covers the switch: the disk
snapshot survives it and is served without a live rebuild, each source-axis
change or a delete/recreate forces a fresh rebuild, and a same-path config
edit still deletes the snapshot.

References

Issues/PRs: #2443, #7540, #7556, #7558. RCAs: t_d127953d, t_16551f61.


Architecture Profile Home Resolve Cache (`docs/architecture/profile-home-resolve-cache.md`)

Profile-home resolve call-scoped cache (`api/workspace.py`)

This document records the current scope, ownership, and freshness contract for
the call-scoped memoization added around _resolve_profile_home_param() in
api/workspace.py. It describes shipped behavior and changes no runtime
behavior. It was added after PR #7636 review flagged this cache lifecycle as
an undocumented runtime contract.

What is cached

  • What: the resolved Path returned by _safe_resolve() for a given
    pre-resolve profile-home Path, inside _cached_safe_resolve_profile_home().
  • Key: the unresolved profile-home Path passed in.
  • Store: a plain dict[Path, Path] held by the
    _PROFILE_HOME_RESOLVE_SCOPE contextvars.ContextVar, which defaults to
    None (no active scope).

Scope: one call, not the process lifetime

The cache is deliberately call-scoped, not process-lifetime. It exists only
for the duration of one wrapped call to _load_cli_sessions_uncached — the
CLI/cron sidebar session-list build that resolves the same profile argument
once per Session row (hundreds of times per request in the common case,
which is the N+1 this cache fixes). profile_home_resolve_cache_scope() sets
the ContextVar to a fresh {} on entry and resets it to the previous value
in a finally block on exit, so no cache entry survives past the single call
it was created for. An earlier revision of this fix cached for the life of the
process; that was reverted in review (see #7636) because Path.resolve() is a
filesystem call, not a pure function of process-startup constants.

Ownership: which call sites get caching

  • profile_home_resolve_cache_scope() is applied, as a decorator, to exactly
    one call site today: _load_cli_sessions_uncached in api/models.py.
  • Every other caller of _resolve_profile_home_param() /
    _cached_safe_resolve_profile_home() — and every call to those functions
    from outside an active scope, including calls that happen before or after
    the one wrapped call — gets no caching: _PROFILE_HOME_RESOLVE_SCOPE.get()
    returns None, and _cached_safe_resolve_profile_home() falls straight
    through to a fresh _safe_resolve() call, exactly matching pre-PR behavior.
  • Nested/reentrant use of profile_home_resolve_cache_scope() restores the
    previous (outer) value on exit rather than clobbering it, so nesting is safe
    but still never leaks a cache entry past the scope that created it.

Freshness guarantee

Because the cache is call-scoped and discarded in finally after each use, a
profile-home symlink retarget or a transient _safe_resolve() fallback
(OSError/RuntimeError/ValueError, which deliberately returns the
unresolved input) is always observed fresh on the next call — the cache
never outlives the call that populated it. The only staleness window is
within a single call, i.e. across the hundreds of Session rows built by one
_load_cli_sessions_uncached invocation — which is correct and intended, since
nothing about a profile's home path should change mid-call.

Change protocol

  1. If a new hot loop needs this memoization, wrap it with
    profile_home_resolve_cache_scope() (as a decorator or a with block) at
    its own call boundary — do not widen the existing scope on
    _load_cli_sessions_uncached to also cover unrelated code.
  2. Do not turn this into a process-lifetime or otherwise longer-lived cache.
    The scoping is the fix for the correctness bugs described above, not an
    incidental detail.
  3. This is a runtime contract: changes here update this document and are
    described in the PR body.

Tests

tests/test_profile_home_resolve_caching.py covers: repeated resolves within
one scope hit the cache once
(test_resolve_profile_home_param_caches_within_one_scope,
test_resolve_profile_home_param_caches_within_one_scope_for_profile_name);
two separate scopes never share state, so a symlink change between them is
observed on the second scope
(test_cache_does_not_persist_across_separate_scopes); calls outside any
scope never cache at all (test_no_caching_outside_any_scope); the decorator
is actually wired onto _load_cli_sessions_uncached
(test_scope_decorator_use_matches_load_cli_sessions_uncached_wiring); and the
N+1 fix itself, both that it dedupes resolves on a cold sidecar-cache miss
(test_load_cli_sessions_uncached_dedupes_profile_home_resolve_on_sidecar_cache_miss)
and that resolve calls scale linearly with the fix disabled
(test_load_cli_sessions_uncached_profile_home_resolve_scales_with_fix_disabled).

References

PR: #7636.


Architecture Send Path Model Provider (`docs/architecture/send-path-model-provider.md`)

Send-path model provider contract (chat start)

This document records the runtime contract for which provider a chat turn is
sent to
, as resolved by the browser before POST /api/chat/start. It
describes shipped behavior and changes no runtime behavior. It was added
because the #7865 review flagged the contract as undocumented: the precedence
rules live in _modelProviderForSend() (static/ui.js) and had only code
comments backing them.

Start here before changing provider precedence for outgoing turns, the picker's
explicit-pick evidence, or how a restored session's provider is resolved.

Why the send path resolves a provider at all

The model name alone is not always enough to route a turn: when two providers
offer the same bare id (for example gpt-5.5 under both OpenAI and OpenAI
Codex, or grok-4.3 under both a custom endpoint and xai-oauth), the
provider decides credentials, endpoint, and protocol translation. The payload
field model_provider is therefore resolved in the browser by
_chatPayloadModelState() (static/messages.js), which delegates to
_modelProviderForSend(model).

The precedence order

_modelProviderForSend(modelId) evaluates these sources in order:

  1. An explicit provider embedded in the model id (@provider:model).
    Authoritative. A qualified id is the user's (or a caller's) exact intent and
    needs no other evidence.

  2. The dropdown's option provider — only with session-scoped pick evidence.
    When the currently selected picker option describes the model being sent,
    its provider wins over the session's stored provider only if the picker's
    session-scoped "explicit pick" marker exists (see below) and still names the
    option that is selected. A bare dropdown match is not evidence of a pick:
    after a session restore, the catalog repaint can leave another provider's
    identically-valued option selected (for example gpt-5.5, offered by both
    OpenAI and OpenAI Codex), and letting that option win would route the turn to
    a provider the user never picked (#7865). The marker is written by the
    picker's change handler for the active session and cleared on session
    load/switch, so a restored session keeps its own provider (#7860).

  3. The session's stored model_provider. Always authoritative for a loaded
    session whose provider the session record itself holds — including every
    restored session, because a restore is not a pick.

  4. The persisted model state (localStorage), when it matches the model
    being sent.
    Used for the empty composer / fresh-session case.

  5. null. The server then applies its own compatible-model resolution.

The explicit-pick marker (why it exists)

A bare dropdown match is not evidence of intent. After a session restore,
syncTopbar() runs before the catalog refresh (static/sessions.js), so
another provider's identically-valued option can be left selected while the
session correctly holds its own model_provider. Letting such an option win
routes the turn to a provider the user never picked — either a provider error
or an answer from the wrong provider (#7865).

The marker closes that gap. It is:

  • Written by the picker's change handler ($('modelSelect').onchange in
    static/boot.js) for the active session, recording the picked value and its
    provider.
  • Read by _modelProviderForSend() and compared against both the active
    session id and the model actually being sent, so a marker can never authorize
    an override for a different session or a different model.
  • Cleared on every session transition: loadSession() clears it for the
    session being left and for same-session force-reloads (a reload is a load —
    the reloaded session's own provider stays authoritative), and newSession()
    clears it for the replaced session.
  • Not consumed by send(). Unlike the _pendingSessionModel family (the
    one-shot explicit-pick signal that send() reads and then clears, #3739),
    this marker survives for the whole session: the evidence "the user picked in
    the dropdown for this session" stays true until the session changes.

Storage is sessionStorage under hermes-webui-explicit-picker-pick:<sid>,
so it is scoped to the browser tab and to one session id.

States that must hold

State Resulting provider
Qualified id @claude:model-x sent claude
Bare id sent, matching dropdown option, pick evidence for this session the option's provider (#7860 fix)
Bare id sent, matching dropdown option, no pick evidence (restored session, collision across providers) the session's provider (#7865 fix)
Pick evidence exists but belongs to another session id the session's provider
Pick evidence exists but was recorded for a different model value the session's provider
Bare id sent, dropdown says nothing about it the session's provider
No session, matching dropdown option the option's provider (empty composer)
No session, no dropdown match persisted state's provider, else null

Tests

  • tests/test_7860_model_provider_send_precedence.py — drives the real
    _modelProviderForSend under node, including the partial-catalog restore
    case, cross-session leakage, and stale-value cases.
  • tests/test_chat_start_provider_fallback.py — payload contract plus the
    pick-evidence gate from the _chatPayloadModelState seam.
  • tests/test_issue7860_model_picker_split_qualified_id.py — how a qualified
    id is split into model + provider by _modelStateForSelect.
  • tests/test_7865_settings_default_provider_survives_qualified_save.py —
    the Settings save path keeps the provider when a qualified option is saved
    as the default (same feature, adjacent regression).

Architecture Session Channel Lifecycle (`docs/architecture/session-channel-lifecycle.md`)

Session channel and stream lifecycle contract

Current contract for the two independent registries that decide whether a WebUI session may
admit a new turn — the per-session SSE channel (SESSION_CHANNELS) and the per-turn stream
(STREAMS + ACTIVE_RUNS + the launch-phase claim) — plus the collection and reclaim rules
that keep a dead consumer or a dead worker from blocking a session indefinitely.

Start here before changing subscriber liveness, channel collection, stream registration,
chat/start admission, or the reaper (#7302).

State layers and ownership

Layer Key Authoritative writer Released by
SESSION_CHANNELS (under SESSION_CHANNELS_LOCK) session_id api/background_process.py channel close + _reaper_loop collection
SessionChannel._subscribers subscriber queue the SSE handler (api/routes.py::_handle_session_sse_stream) subscribes; the channel owns the list handler finally (unsubscribe)
SessionChannel._last_write_ok_at subscriber queue the SSE writer, via note_subscriber_write_ok() after every COMPLETED write unsubscribe()
SessionChannel._stalled_since subscriber queue emit() records a queue.Full run; the collection path clears it unsubscribe() / drain
STREAMS (under STREAMS_LOCK) stream_id the launch sites in api/routes.py and the gateway reattach in api/gateway_chat.py release_stream_owned_registries()
ACTIVE_RUNS (under ACTIVE_RUNS_LOCK) stream_id the worker admission paths (api/streaming.py, api/gateway_chat.py) worker finally, or Stop
PRE_ADMISSION_CLAIMS (under STREAMS_LOCK) stream_id the launch site that registered the stream worker admission, Stop, launch failure, canonical teardown
Stream-owned siblings — owner, goal marker, partial/reasoning text, live tool calls, last event id stream_id the stream's writer/handler _release_stream_owned_rows()
Gateway run rows (api/gateway_chat.py) stream_id the gateway run release_gateway_stream_state()

stream_owned_registries() resolves that set on EVERY call (module attribute lookup, not a frozen
tuple), so a rebound registry is never leaked by a teardown holding the old object.

Launch phase: registration → admission

  1. Register. Create the channel, register_stream_owner(), and inside ONE STREAMS_LOCK
    critical section publish STREAMS[stream_id] and publish_pre_admission_claim() — on
    every launch edge: ordinary start, regeneration, /btw and /api/background. All four are
    load-bearing since the reaper sweeps orphans (a claimed stream whose worker has not been
    admitted yet is launching, not dead).
  2. Launch. The worker is scheduled but not yet admitted. The regeneration path holds it at
    release_worker.wait() while s.save() runs, so this window is unbounded in practice.
  3. Admit. The worker registers in ACTIVE_RUNS and retires the claim on the same lock
    edge, after the registration
    — so there is never a moment with neither blocker.
  4. Tear down. retire_pre_admission_claim_if_owned() retires by identity: in Stop (after the
    cancellation is published, before ownership is detached), on launch failure, and
    _release_stream_owned_rows() pops it in the canonical funnel.

Subscriber liveness and channel collection

reaper_should_collect(now) is true when any of these holds:

  • no subscribers, and last_subscriber_drop_at is older than
    SESSION_CHANNEL_SUBSCRIBER_GRACE_SECS (60 s) — normal teardown;
  • no subscribers AND the channel has outlived SESSION_CHANNEL_IDLE_TTL_SECS (14400 s) — a
    hard lifetime cap that also sweeps a channel whose subscribers oscillated. With a subscriber
    attached the only admissible evidence is the dead-subscriber signal below: age alone never
    collects a channel that still has one
    ;
  • every attached subscriber is dead, where a subscriber is dead when EITHER
    • its queue rejected broadcasts (queue.Full) continuously for
      SESSION_CHANNEL_SUBSCRIBER_STALL_SECS (300 s) — cleared as soon as the queue has capacity
      again, because a dequeue is progress; OR
    • no write to it COMPLETED for max(STALL_SECS, 3 × SESSION_CHANNEL_KEEPALIVE_SECS).

A single live subscriber protects the channel.

Writer-side liveness exists because queue pressure is blind to an idle session: nothing is
emitted, so a 64-slot queue never fills, so a half-open socket (client gone, no FIN ever
reaching us) looks exactly like a quiet healthy one. The keepalive write is the only thing a
healthy idle subscriber completes, which is what makes "no completed write for a whole window"
positive evidence of death rather than evidence of quiet.

⚠️ Coupling. SESSION_CHANNEL_KEEPALIVE_SECS (api/background_process.py) mirrors
_SSE_HEARTBEAT_INTERVAL_SECONDS (api/routes.py) as a plain number because routes imports
background_process — importing back would be circular. Keep them in step: a real interval at
or above the staleness window would let the signal collect a healthy idle subscriber.
tests/test_7302_writer_liveness.py pins the mirror.

The orphan definition (one predicate, three readers)

api/config.py::is_orphaned_stream(stream_id, *, pending_turn_in_window=False, streams_lock_held=False)
is orphaned only when all of these hold: registered in STREAMS; no row in ACTIVE_RUNS (fail
closed if that registry cannot be read); no PRE_ADMISSION_CLAIMS entry; and the caller's
pending_turn_in_window is false.

  • _active_stream_blocks_chat_start — keeps a non-orphan; a confirmed orphan is cleared from the
    whole stream-owned set so the next chat/start is admitted.
  • _session_has_active_turn — an orphaned stream must not keep reporting the session as busy.
  • _reaper_loop — reclaims orphaned streams through release_orphaned_stream_if_still_orphaned(),
    which re-decides and releases on one STREAMS_LOCK edge (a decision followed by a separate
    release would let a worker admit itself in the gap and lose a live stream), plus
    release_gateway_stream_state() outside the lock.

The claim check is mandatory. Without it a stream that is still launching is classified as
orphaned and reaped, and its first worker exits without running its turn (#7302, maintainer
finding 5).

Lock discipline

  • STREAMS_LOCK → ACTIVE_RUNS_LOCK is the documented order (Stop/Steer and both admission
    paths). Never take them the other way.
  • Snapshot one lock's contents, release it, then take the other — as _emit_to_session_streams
    and the reaper's orphan sweep do.
  • threading.Lock is not reentrant: helpers reachable with STREAMS_LOCK already held take
    streams_lock_held=True.
  • Settle the decision and the mutation on the same lock edge: emit, stall record, claim, close.

Invariants

# Invariant Enforced in Pinned by
I1 A registered stream whose launch-phase claim is not retired keeps blocking chat/start, whatever the pending age. _active_stream_blocks_chat_start tests/test_7302_pre_admission_claim_barrier.py
I2 The claim is published in the same critical section as the STREAMS entry on every launch edge (ordinary start, regeneration, /btw, background), and retired by identity at admission, Stop, launch failure, and the canonical teardown. api/config.py, all four launch sites, cancel_stream tests/test_7302_pre_admission_claim_barrier.py
I3 A stream is an orphan under exactly one definition, and the launch claim is part of it. is_orphaned_stream() tests/test_7302_orphan_stream_predicate.py
I4 A healthy idle subscriber can never be collected: it proves itself on every keepalive. writer-liveness signal tests/test_7302_writer_liveness.py (negative control)
I5 A subscriber that just proved liveness is never closed by a decision taken on stale state. revalidation under the channel lock same file
I6 Only the exact stream id is cleared, and the whole stream-owned set goes together. release_stream_owned_registries() tests/test_reaper_drain_progress.py, tests/test_session_channel_explicit_close.py
I7 An orphan is reaped only while it is still an orphan: the decision and the release share one STREAMS_LOCK edge. release_orphaned_stream_if_still_orphaned() tests/test_7302_orphan_stream_predicate.py

What not to do

  • Do not infer worker liveness from STREAMS membership alone.
  • Do not publish an ACTIVE_RUNS row to cover the launch window — that is the phantom-row class
    finding 3 retired; use the launch-phase claim.
  • Do not add a second definition of "orphan".
  • Do not collect a channel on queue pressure alone: it is blind to idle sessions.
  • Do not nest STREAMS_LOCK inside ACTIVE_RUNS_LOCK.

Architecture Stable Assistant Turn Anchor Phase0 (`docs/architecture/stable-assistant-turn-anchor-phase0.md`)

Stable Assistant Turn Anchors Phase 0 Inventory

This inventory began as the first non-visual slice of
stable-assistant-turn-anchors.md and
now records the shipped phase progression. Current master wires Anchor-owned
activity_scene_v1 data into live and settled Compact Worklog, Transparent
Stream, journal hydration, session re-entry, and transcript-backed persistence.

The slice-by-slice sections below are retained as implementation history.
Present-tense statements such as “unwired” or “future phase” describe the point
when that slice landed; they do not override the current coverage summary above.

Shipped RFC Phase Progress

  • The #3962 Phase 0 scaffold shipped through #3977 / v0.51.359: inventory the
    current state layers, encode the owner seed, and pin the source classification
    contract.
  • PR #3980 / v0.51.366 delivered the first RFC Phase 2 foundation: normalize
    current live, replay, and settled source events into anchor-shaped events while
    staying unwired from rendering.
  • This slice advances RFC Phase 1 and Phase 2 together: it adds a local registry
    owner plus a shadow source-feed harness that can combine live, replay,
    settled, and in-flight observations into one anchor snapshot.
  • It also covers the RFC Phase 2.5 contract-hardening boundary: the semantic
    anchor seed excludes renderer presentation state, terminal states are exposed
    as constants with alias normalization, and replay + settlement ordering is
    pinned by tests before visible wiring begins.
  • Slice 4 starts RFC Phase 3 by routing settled assistant final prose through the
    anchor owner before renderMessages() renders the final assistant body.
  • Slice 5 starts RFC Phase 5 by projecting anchor-owned activity events into a
    renderer-neutral activity scene that Compact Worklog and Transparent Stream
    can later consume from the same ordered rows.
  • Slice 6 starts the live shadow-feed boundary: attachLiveStream() now creates
    a per-stream anchor registry and feeds non-token live activity events into it
    without changing either current renderer.
  • Slice 7 adds the dual-run reconciler for the renderer handoff: it compares a
    current Compact Worklog / Transparent Stream renderer-row snapshot with the
    anchor-owned activity_scene_v1 rows and reports missing rows, extra rows,
    order changes, and field mismatches before visible renderer replacement.
    S.messages, INFLIGHT, stream-local state, and DOM nodes remain
    projection/cache layers outside the settled final-prose path and the live
    shadow registry.
  • Slice 8 adds the renderer snapshot adapter that can extract
    renderer_snapshot_v1 summaries from current Compact Worklog /
    Transparent Stream row hooks and feed them through the reconciler to produce a
    concrete matched / mismatched answer. The adapter remains opt-in and is not
    invoked by renderMessages() or the live SSE hot path.
  • The first visible-order handoff switches live Compact Worklog rendering from
    legacy DOM mirroring to the per-stream anchor activity_scene_v1 projection
    for same-browser active streams. Visible process prose, reasoning rows, and
    tool start/complete boundaries are represented as ordered scene rows. The
    existing Compact Worklog writers remain as fallback paths only when no anchor
    scene is available, and settled assistant messages may carry an in-memory
    _anchor_activity_scene snapshot so the folded activity summary can appear
    above the final answer. This handoff does not claim Transparent Stream wiring
    or durable hard-reload scene persistence.
  • The first RFC Phase 6 ownership slice routes the production state_saved SSE
    into the active Assistant Turn Anchor and carries artifacts and
    side_effects through activity_scene_v1 settlement persistence and reload
    hydration. These owned outcomes remain off the readable Worklog by default;
    memory/skill saves still surface through their existing toast and natural UI.
    Production artifact-reference derivation and run-journal reconstruction of
    side effects remain follow-up work.

State Layers

Settled reasoning reconciliation allocates transcript slots only for reasoning
that survives the visible-prose duplicate and final-answer filters. Filtered
metadata must not consume or discard a distinct saved reasoning event. Durable
event identity remains authoritative for deduplication and reload.
Without a content[] tool_use boundary, only reasoning metadata emits a
transcript Thinking row. Missing or empty metadata must not turn unrendered
thinking/reasoning content parts into slots that discard distinct saved events.
Top-level tool_calls, partial calls, and external tool records do not change
that content boundary.
When a saved identified prose event matches an identity-less transcript row,
inherit only its durable identity fields (event_id, row_id, or legacy
local_id). The transcript row retains its exact Markdown text, payload, source,
and grouping metadata; whitespace-normalized equality does not make a trimmed
or flattened live prose copy authoritative for the body. Thinking reconciliation
continues to preserve the saved event's text and identity.

Layer Current surface Phase 0 anchor policy
RuntimeAdapter / run-journal Event Envelope event_id, run_id, seq, Last-Event-ID / after_seq Preferred identity and replay dedupe source.
Run journal replay events read_run_events(), _replay_run_journal, runtime_journal_snapshot Durable replay hydration source before browser caches.
Server settled transcript /api/session messages and metadata Settlement updates final answer and terminal state on an existing turn.
S.messages Browser transcript projection consumed by renderMessages() Projection/cache, not a second semantic owner.
INFLIGHT Browser recovery cache and persisted localStorage state Recovery fallback only; does not outrank journal or settled transcript.
Stream closure state attachLiveStream() local assistant text, reasoning text, parser target, tool state Hot-path write buffer; future phases normalize this into anchor events.
Live DOM #liveAssistantTurn, Worklog rows, tool cards, Thinking cards Renderer output only; DOM survival is not semantic truth.

The same inventory is encoded in static/assistant_turn_anchors.js as
HermesAssistantTurnAnchors.stateLayers so tests can pin the current authority
order.

Slice 2 Normalizer Helper

HermesAssistantTurnAnchors.normalizeAssistantTurnAnchorSourceEvent() converts a
single current source event into a normalized anchor event envelope without
registering it, rendering it, or mutating browser state. It accepts live SSE-like
events (type, data, lastEventId), replay/journal-like events (event,
payload, event_id, seq), and settled/session payload events such as
settled_message.

HermesAssistantTurnAnchors.normalizeAssistantTurnAnchorSourceEvents() applies
the same helper to a list and dedupes repeated live + replay observations by the
same event-envelope key. This is still inert: send(), attachLiveStream(),
renderMessages(), settlement restore, S.messages, INFLIGHT, and the DOM do
not consume the helper yet.

Slice 3 Registry / Owner Skeleton

HermesAssistantTurnAnchors.createAssistantTurnAnchorRegistry() creates a local
owner object for one assistant turn. The registry contains the anchor seed, a
dedupe index, and application stats. It is not a global store and is not wired
into current runtime, session, or renderer code.

HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent() and
applyAssistantTurnAnchorSourceEvents() normalize incoming source events, apply
the same event-envelope dedupe rule, and route events into one owner:

  • activity_events for visible assistant activity such as prose, reasoning,
    tools, control boundaries, and terminal status
  • artifacts for workspace/file references
  • side_effects for persisted state side effects
  • metadata_events for settlement/session metadata such as settled_message
  • transport_events for transport-only signals such as stream_end

The registry may fill missing run_id / stream_id identity from the first
matching normalized event, update lifecycle on terminal status, and copy the
settled assistant message into content.final_answer as a derived render
snapshot while keeping content.final_message_ref as the settled transcript
reference. It rejects mismatched session or turn identity and skips duplicate
live + replay observations by the same dedupe key.

This slice deliberately keeps the ownership boundary inert: send(),
attachLiveStream(), replay hydration, renderMessages(), S.messages,
INFLIGHT, and DOM continuity still do not consume the registry. Later slices
can replace local renderer-owned state with this owner instead of adding another
parallel source of truth.

HermesAssistantTurnAnchors.createAssistantTurnAnchorShadowSnapshot() is the
shadow wiring harness for this slice. It accepts grouped live_events,
replay_events / run_journal_events, settled_events, and inflight_events,
feeds them through one local registry, and returns the resulting snapshot plus
per-source apply results. This gives later slices an invariant target without
making the current UI consume the owner yet.

Renderer-only UI state such as Compact Worklog expansion, Transparent Stream
expansion, copy-button visibility, and scroll-follow preference is intentionally
not stored in the anchor seed. Those choices belong in renderer state or a
separate per-session UI preference store so replay and settlement do not carry
historic display preferences as semantic facts.

HermesAssistantTurnAnchors.terminalStates exposes the RFC terminal-state enum:
completed, cancelled, interrupted, no_response,
tool_limit_reached, compression_exhausted, connection_lost, degraded,
and error. normalizeAssistantTurnAnchorTerminalState() maps current source
aliases such as done, cancel, apperror, interrupted-by-user,
max_iterations, and lost_worker_bookkeeping into that enum.

During the later INFLIGHT migration, the registry is the semantic owner for
event identity, lifecycle, final answer reference, and activity events.
INFLIGHT.lastRunJournalSeq, activityBurstAnchors, currentLiveSegmentSeq,
streamId, and cached live text/tool state remain recovery or renderer caches
until the matching field is explicitly moved. The fallback order is journal
replay first, settled transcript second, INFLIGHT only for gaps.

Slice 4 Settled Final Projection

HermesAssistantTurnAnchors.projectAssistantTurnAnchorSettledMessageFinalAnswer()
projects one settled assistant transcript message through a local anchor
registry. The settled transcript message reference remains the semantic
authority (content.final_message_ref); content.final_answer is a derived
render snapshot for the existing markdown pipeline.

renderMessages() uses that projection only for settled assistant messages
(!isUser && !m._live) and only after preserving the current content-array
flattening behavior. It then continues through the existing inline-thinking and
markdown rendering pipeline. If the anchor helper is unavailable or cannot
produce a final answer, renderMessages() falls back to the existing message
content path.

This is intentionally narrower than render-scene ownership: live stream tokens,
replay hydration, worklog rows, transparent-stream rows, tool cards, INFLIGHT,
and DOM continuity are still not consumed by the anchor registry in this slice.

Slice 5 Activity Scene Projection

HermesAssistantTurnAnchors.projectAssistantTurnAnchorActivityScene() projects
an anchor or registry into activity_scene_v1: identity, lifecycle,
final_answer, final_message_ref, terminal state, and an ordered
activity_rows list.

The rows are renderer-neutral. Compact Worklog receives display hints such as
main_prose, collapsed_thinking, tool_row, and terminal_status_row.
Transparent Stream receives the same row IDs, order, kinds, roles, text, tool
IDs, and sanitized payloads with a chronological display hint. This pins the
shared input shape before either renderer is rewired.

This slice is still inert. No current UI module consumes the activity scene.
renderMessages() and the live streaming hot path were unchanged by Slice 5.

Slice 6 Live Shadow Feed

attachLiveStream() now creates or reuses a per-stream local registry in
window._liveAnchorRegistries and feeds current live activity events through
HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent(). This is a
shadow feed only: Compact Worklog, Transparent Stream, renderMessages(),
S.messages, INFLIGHT, and DOM continuity do not read from the registry yet.

The feed intentionally skips token events. Token events can arrive at high
frequency and would turn the anchor into a per-token append log before the
renderer reconciliation slice has proven the row model. Reasoning deltas are
also not fed one-by-one; Slice 6 flushes one aggregate reasoning event before a
terminal or settled-restore path. The feed captures the non-token activity
boundaries that define the future scene: interim assistant segments, tool
start/complete, approval, clarify, goal continuation, pending steer leftovers,
compression lifecycle, app errors, cancel, and done.

The SSE Last-Event-ID value is copied into the source event before applying it
to the registry, with current event-id fallbacks preserved. Existing registries
are reused by stream_id so a reconnect continues the same dedupe ring instead
of starting a parallel owner. Completed, errored, or cancelled streams schedule
registry cleanup after a retention window. Permanently failed network-error
paths schedule a shorter cleanup window after recovery/restore options are
exhausted.

The done feed is deliberately slim: status, usage, and creation timestamp are
copied, but the full settled session payload is not duplicated into the live
registry. When the active settled assistant message is available, it is stamped
with _anchor_stream_id so later reconciliation can associate the settled
message with the live shadow registry. That field is treated as client-side
ephemeral turn metadata and is carried forward across session refreshes.

EventSource network error remains a transport/recovery signal and is not fed
as an anchor terminal event in this slice. Runtime app errors continue through
the existing apperror event path and are fed as terminal activity only when
they match the current session.

Slice 7 Dual-Run Reconciler

HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorActivityScene() compares
the anchor-owned activity_scene_v1 projection against a renderer-derived row
snapshot. It is a shadow harness, not a renderer. Callers pass the current
renderer's observed rows as plain summaries, and the helper returns
activity_scene_reconciliation_v1 with:

  • expected and actual row summaries,
  • the comparison fields used,
  • row-count, missing-row, unexpected-row, order, and field mismatch diagnostics,
  • identity and terminal-state context from the anchor scene.

This slice keeps the comparison renderer-neutral. Compact Worklog and
Transparent Stream can each provide their own row snapshots, while the expected
side always comes from the same anchor scene. Matching rows prove the current
renderer can be replaced by an anchor-backed renderer for that event shape;
mismatches identify the specific event kind, tool identity, status, or ordering
gap that must be fixed before the visible handoff.

No current hot path consumes the reconciler. renderMessages(), live SSE
callbacks, S.messages, INFLIGHT, Compact Worklog, Transparent Stream, and
DOM continuity continue to render exactly as before until a later replacement
slice deliberately switches a renderer to anchor-owned rows.

Slice 8 Renderer Snapshot Adapter

HermesAssistantTurnAnchors.createAssistantTurnAnchorRendererSnapshot() turns
current renderer rows into renderer_snapshot_v1 summaries. The helper accepts
plain row-like objects or a DOM/root object with the existing renderer hooks:
Transparent Stream rows (.transparent-event-row /
data-transparent-event-row), Compact Worklog reasoning rows (.wl-reason,
.agent-activity-thinking, .thinking-card-row), and Compact Worklog tool rows
(.tool-card-row).

HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorRendererSnapshot() is
the first one-call yes/no harness: it builds or accepts a renderer snapshot,
passes its rows into reconcileAssistantTurnAnchorActivityScene(), and returns
renderer_snapshot_reconciliation_v1 with matched: true or matched: false
plus the underlying mismatch diagnostics.

This slice still does not change visible rendering. It gives later work a
bounded way to ask whether the current renderer output is equivalent to the
anchor-owned activity scene. A matched: false result is expected while current
renderers intentionally collapse or omit events, such as representing a tool
start + tool completion as one visible row or omitting terminal status rows.

Compact Worklog Visible-Order Handoff

The first renderer handoff deliberately targets the live Compact Worklog path
only. attachLiveStream() keeps the existing streaming markdown parser as a hot
write buffer, but every visible process-prose segment is upserted into the
per-stream anchor registry as a single process_prose row. Reasoning and tool
events enter the same registry before their legacy renderer callbacks run, so
the projected activity_scene_v1 owns the visible row order.

renderLiveAnchorActivityScene() consumes only that projected scene for the
active live turn. Once the live turn is anchor-owned, legacy live
appendLiveToolCard(), appendThinking(), auto-compression, and Worklog
reason-mirroring paths re-render or exit instead of creating a second activity
rail. On same-browser session switch, loadSession() tries the live anchor
scene before falling back to saved live DOM snapshots or persisted INFLIGHT
tool replay.

At settle time, the active final assistant message may receive the current
in-memory _anchor_activity_scene. renderMessages() uses that snapshot to
build a folded activity summary above the final answer and leaves the final
answer as ordinary assistant prose. Successful auto-compression rows remain
live-only in settled history unless they explain a visible error or recovery
state. The original live Compact Worklog handoff did not by itself cover
Transparent Stream or durable reload; those require explicit settled-scene
persistence or read-side hydration slices.

Settled mixed content[] assistant messages now bridge into the same
activity_scene_v1 ownership. When the final assistant message interleaves text
parts with tool_use parts, settlement and read-side hydration promote those
ordered parts into anchor activity rows. The final answer remains the text after
the last tool use on the final assistant message, while earlier text, non-final
post-tool process text, thinking rows, and tool rows stay in chronological
activity order for Compact Worklog and Transparent Stream renderers. The
Transparent Stream raw content[] helper remains a fallback for settled
messages that do not yet carry _anchor_activity_scene.

Settled Fallback Ownership Matrix

After the mixed content[] bridge, _anchor_activity_scene is the semantic
owner for settled assistant activity whenever it is present. Legacy raw-message
paths stay as compatibility fallbacks for older or non-anchor transcripts; they
must not compete with an anchor-owned turn.

Surface Anchor-present owner Compatibility fallback Fallback exit
Settled Compact Worklog activity _renderSettledAnchorSceneForMessage() renders activity_scene_v1 rows before the final answer. Legacy S.toolCalls, tool_calls, _partial_tool_calls, and raw content[].tool_use rebuilds. anchorOwnedAssistantRawIdxs excludes the anchor-owned turn from message metadata scans, fallback source collection, tool buckets, thinking buckets, and worklog-source mirroring.
Settled Transparent Stream activity _renderSettledAnchorSceneTransparentForMessage() renders the same scene rows as transparent event rows or inline prose before the final answer segment. _transparentStreamOrderedParts() rebuilds raw content[] order for historical messages without an anchor scene. _transparentStreamOrderedParts() returns null when message._anchor_activity_scene exists, leaving the dedicated anchor renderer in charge.
Historical / non-anchor transcripts Complete, user-bounded transcripts with unique top-level assistant.tool_calls[].id declarations, exactly one matching role=tool tool_call_id result per declaration, and a later final assistant answer hydrate an in-memory activity_scene_v1 through the Anchor module adapter before settled rendering. Raw content[], persisted session.tool_calls, role=tool rows, and partial tool-call snapshots continue to recover ambiguous, incomplete, or richer historical shapes. The ID-linked shape exits fallback ownership only after strict turn-local validation; missing or duplicate IDs/results, cross-turn matches, process prose/reasoning, and other tool schemas fail closed to the legacy renderer.
Live reattach / session switch renderLiveAnchorActivityScene() consumes the projected live scene, and session switch first attempts runtime-journal anchor scene restore. Saved live DOM snapshots and INFLIGHT tool replay. Snapshot fallback only runs when no usable live anchor scene can be rendered.

This matrix is the current settled-render contract, backed by runtime-journal
hydration parity and behavior-level renderer ownership coverage. Fallback paths
are compatibility-only rebuilds for historical or genuinely non-anchor
transcripts, and the renderer must gate them by explicit raw transcript indexes
so object identity or duplicate message references cannot let fallback rows
compete with an anchor-owned turn. Removing a compatibility path still requires
new evidence that the supported transcript shape hydrates through anchors.

Source Event Classification

Phase 0 classifies current sources before changing render behavior:

  • activity: token, interim_assistant, reasoning, tool,
    tool_complete, tool_update, compressing, compressed, approval,
    clarify, pending_steer_leftover, goal_continue, done, cancel,
    error, apperror
  • artifact: artifact_reference
  • side effect: state_saved
  • metadata: usage, title, settled_message, runtime_journal_snapshot,
    inflight_snapshot
  • transport: stream_end

Future phases may add sources, but every source must choose one of these classes
or explicitly mark itself excluded.

Dedupe Invariant

Anchor event dedupe is intentionally independent of visible text and timestamps.
The Phase 0 helper uses this order:

  1. event_id
  2. run_id + seq
  3. session_id + source_event_type + local_id + seq as a browser fallback only
    when a concrete local seq is present

This mirrors the RuntimeAdapter Event Envelope and keeps the browser aligned
with run-journal replay while the anchor registry is still unwired.

The registry tests also pin the reconnect/settlement race shape: if one run is
observed live, replayed, and settled in either order, duplicate event envelopes
are skipped and the resulting anchor has the same activity list, terminal state,
final message reference, final-answer snapshot, and usage metadata.


Architecture Transcript Auto Follow Scroll (`docs/architecture/transcript-auto-follow-scroll.md`)

Transcript Auto-Follow Scroll Contract

  • Status: Implemented
  • Applies to: static/ui.js scroll-follow state machine, static/style.css
    .messages scroller, and the queued live-render restore paths.
  • Regression coverage: tests/test_fast_stream_shrink_clamp_unpin.py,
    tests/test_issue5637_stale_anchor_guard.py,
    tests/test_issue3319_pinned_scroll_jump.py,
    tests/test_pinned_restore_stale_bottom_gap.py,
    tests/test_scroll_collapse_clamp_keeps_follow.py,
    tests/test_2111_ios_pwa_bottom_scroll_stutter.py, and
    tests/test_mobile_layout.py.

This document is the authority for how the transcript decides to stay glued to
the live tail, when reader input releases that glue, and how a reader regains
it. The scroll listener, the settle writer, and the queued live-render restore
all consult this same state, so a change here must keep all three consistent.

State model

Three pieces of state own the outcome, all inside static/ui.js:

  • _scrollPinned — the live-tail follow latch. While true, every streamed
    write re-anchors the viewport to the bottom.
  • _messageUserUnpinned — the sticky reader-takeover flag. Reader input sets
    it; only a genuine return to the tail (or the bottom jump control) clears it.
  • _messageScrollInputGeneration — a monotonic ownership token bumped by every
    real reader input (wheel, touch, keyboard, scrollbar drag). Delayed restores
    compare their captured generation against it to distinguish "input happened
    after the snapshot" from "input merely happened recently". The wheel/touch
    bump additionally requires the event to target the transcript scroll surface
    (see the capture rule below) so input consumed by a nested scroll surface
    never becomes transcript re-pin authority.

Input-tail capture and reader-resume re-pinning (catch-tail contract)

During a fast stream (200+ tok/s) the true bottom moves DOWN between the
reader's wheel event and the scroll handler running. Distance measured against
the freshly-grown scrollHeight chronically reads far from the tail, so an
unpinned reader chasing the tail could never satisfy a "reach bottom" re-pin
gate.

The contract that closes that race:

  1. Capture at input. Every real reader input captures the tail height at
    the moment of the event (_captureMessageScrollInputTail), BEFORE the
    browser applies that input, and stamps it with the input generation. Event
    values are the only authority for "the tail the reader was aiming at";
    scrollHeight observed later by a scroll callback is not, because streaming
    can add arbitrary height between the event and the callback. The capture
    fires only when the input actually targets the transcript scroll surface:
    wheel/touch events aimed at a nested scroll surface (tool output pane, code
    block, approval command view) are consumed by that surface — they never move
    the transcript — so they must not mint re-pin authority
    (_isTranscriptScrollTarget walks the target's ancestor chain; any vertical
    scroller between the target and .messages consumes the gesture). The one
    exception is boundary chaining: a nested scroller pinned at the boundary in
    the gesture's direction (deltaY>0 wheel at the pane's bottom edge, or a
    dy<0 touchmove — finger moving up — there) cannot scroll that pane, so the
    browser passes the gesture on to the transcript itself and the capture
    survives unless the nested pane sets overscroll-behavior-y: contain or
    none (both block chaining even at the boundary). The opposite direction
    still consumes (the pane can scroll that way), and the keyboard path passes
    an explicit direction (keyDir, -1 for PageUp/ArrowUp/Home/Shift+Space,
    else +1) so its boundary chaining is evaluated with the key's real
    direction. The transcript's own scrollbar is exempt from the nested check
    because it belongs to .messages. The keydown path applies the check to the
    focused element, falling back to .messages itself when focus is outside
    the transcript (for example on `` with the pointer over it).
  2. Consume once. Each captured input tail authorizes exactly one scroll
    event (_messageScrollInputTailConsumedGeneration). A later programmatic or
    layout scroll cannot reuse stale authority — this is what prevents the
    false re-pins that earlier per-callback-height versions reintroduced.
  3. Arrive → re-pin. If a downward scroll carries the viewport to within
    80px of the INPUT-CAPTURED tail, that arrival is decisive re-pin intent: the
    reader re-pins immediately (no debounce — at fast stream rates a second
    qualifying event may never come) and the tail snaps to bottom.
  4. Resume auto-follow. Re-pinning clears _messageUserUnpinned and sets
    _scrollPinned=true, so the next streamed write follows again. A reader who
    does not reach the captured tail stays unpinned; proximity inside the
    ~250px near-bottom band alone never re-pins (the #4295 invariant).
  5. Reader input wins over queued restores. A queued live-render restore
    (rAF after an activity-scene rebuild) re-checks the input generation at rAF
    time. If the reader provided input between capture and frame, the restore
    abandons the stale snapshot instead of writing it — real input outranks the
    captured tail position.

The companion recovery rule: while pinned, a rebuild restore targets the
POST-rebuild tail exactly (the pre-rebuild gap is stale once content grew), so
the restore is idempotent with the follow writer instead of racing it across
paint frames.

Why the transcript scroller suppresses elastic overscroll

.messages sets overscroll-behavior-y: none rather than contain:

  • contain prevents scroll chaining but deliberately keeps the platform's
    elastic overscroll effect at the boundary.
  • While live-tail follow writes the hard bottom on every streamed update, that
    elastic transform is repeatedly cancelled and re-started, which reads as a
    bottom-edge vibration on touch devices during streaming.
  • none preserves the isolated scroll surface (gestures still do not chain
    into the app body) while also suppressing the boundary effect, so the follow
    writer and the platform rubber-band never fight for the same pixels.

This only applies to the transcript scroller. Other scroll surfaces (session
list, clarify card, kanban columns) keep contain/default because nothing
writes to them continuously.

Manual verification checklist

Desktop, narrow/mobile width, and long streaming content per the UI/UX guide:

  1. Start a long streaming response on desktop: the tail stays glued; wheel up
    (any amount, even a small trackpad gesture) releases follow immediately.
  2. While unpinned mid-stream, wheel down toward the tail: arriving within a
    small distance of the tail you were aiming at re-pins and follows again.
  3. Same on a narrow/mobile viewport with touch: swipe up releases, swipe down
    to the tail re-pins, and there is no bottom-edge vibration while pinned
    during streaming (the overscroll suppression above).
  4. During a live stream, open a jump/queue/compression card that shrinks or
    grows the transcript while pinned: no up-then-snap bounce.
  5. While unpinned mid-stream, wheel inside a nested scrollable pane (tool
    output, code block, approval command view) and then let the stream grow the
    transcript: the reader must NOT be yanked back to the tail — input consumed
    by a nested surface leaves no re-pin authority. Wheeling over a bare
    transcript message still enables a catch-tail re-pin.
  6. While unpinned mid-stream, wheel DOWN over a nested pane that is already
    scrolled to its bottom edge during a fast stream: the gesture chains to the
    transcript, so arriving at the tail you were aiming at still re-pins. Wheel
    UP over that same bottom-pinned pane (consumed by the pane) must not.

Regression suites: run the files listed at the top with ./scripts/test.sh.


Architecture Unified Session Db (`docs/architecture/unified-session-db.md`)

Unified SessionDB Adapter Spike

WebUI currently persists conversations as JSON files under the WebUI session
directory, while the CLI uses its own session database. The first safe slice of
unification is a dormant adapter that presents a small SessionDB-shaped API over
the existing WebUI JSON files without changing runtime call sites or file
format.

Adapter Contract

api.webui_session_db.WebUIJsonSessionDB exposes:

  • list_sessions() returns compact metadata rows for persisted WebUI JSON
    sessions.
  • read_session(sid) returns a full session JSON payload or None.
  • update_metadata(sid, fields) writes only allowlisted metadata fields and
    rejects unsafe keys such as session_id, messages, tool_calls, and
    message_count.
  • archive(sid, archived=True) is a convenience metadata update for the
    archived flag.
  • write_session(session) exists for tests and migration experiments that need
    to materialize a complete JSON payload.

Read operations must not call Session.load() or all_sessions(), because
those paths can repair indexes or transcripts. Metadata writes must load the
complete JSON payload, verify that a real messages list is present, update only
safe fields, recompute message_count, and atomically replace the file. The
adapter must never write a metadata-only stub that could drop transcript
messages.

Why JSON-Backed And Dormant

The selected first slice is infrastructure only. Keeping the adapter backed by
the current JSON sidecars validates the API shape while preserving all current
WebUI behavior, backups, and import paths. The feature flag defaults to:

yaml
experimental:
  unified_session_db: false

No UI exposes this flag, and no runtime session route switches to the adapter in
this slice.

Runtime Wiring Preconditions

Before any route uses this adapter for live metadata changes, a follow-up PR must
prove parity with the existing Session.save() path:

  • take the same per-session mutation locks used by streaming and session routes,
    so metadata writes cannot replace a newer transcript with a stale copy;
  • refresh or invalidate the in-memory Session cache and _index.json, so
    sidebar rows and later Session.save() calls cannot overwrite adapter changes;
  • match Session.compact() sidebar semantics for pending first turns,
    has_pending_user_message, pending_started_at, and real non-tool
    last_message_at ordering.

Until those invariants are implemented, update_metadata() and archive() are
test/migration helpers, not runtime persistence replacements.

Planned Migration Sequence

  1. Land the dormant JSON adapter and contract tests.
  2. Add parity tests that compare adapter reads with existing WebUI sidebar and
    session payloads.
  3. Introduce an opt-in dual-read or shadow-read mode for development builds.
  4. Add a migration path that can write unified SessionDB records without
    deleting or rewriting JSON sidecars.
  5. Switch selected call sites behind the flag only after parity and rollback
    behavior are proven.
  6. Make the unified store authoritative in a later release after import,
    archive, pin, profile, project, and recovery semantics match WebUI JSON.

Authoritative Fields And Open Questions

The JSON sidecar remains authoritative for messages, tool_calls, metadata
display fields, profile/project ownership, archive and pin state, token/cost
totals, pending stream recovery fields, worktree metadata, and composer draft
state during this spike.

Open questions for later slices:

  • Whether updated_at should reflect metadata-only changes such as archive and
    pin operations or only transcript changes.
  • How to resolve conflicts when CLI and WebUI update titles, archive state, or
    project/profile ownership concurrently.
  • Whether imported CLI sessions remain read-only projections or become editable
    unified records.
  • How unified records should preserve WebUI recovery safeguards such as backup
    creation before transcript shrinkage.
  • Which store owns sidebar ordering once JSON and SessionDB records coexist.

Out Of Scope

This spike does not switch runtime WebUI call sites, migrate existing session
files, expose a UI setting, alter CLI storage, change session import behavior, or
remove any JSON sidecars. It is a contract and safety test bed for future
migration work.


Rfcs Agent Source Boundary (`docs/rfcs/agent-source-boundary.md`)

Agent Source Boundary and API Decoupling Inventory

  • Status: Proposed
  • Created: 2026-05-17
  • Tracking issue: #2453

Problem

The WebUI currently depends on Hermes Agent Python source being importable at
runtime. In local installs this usually means a neighboring checkout; in the
multi-container Docker setup it means the WebUI reads the hermes-agent-src
volume that the agent container also uses.

That source mount is a compatibility bridge, not the desired long-term contract.
Even when mounted read-only on the WebUI side, it couples WebUI releases to
Hermes Agent internal module layout and makes the multi-container setup look more
isolated than it really is.

Current safety posture

  • The multi-container compose files mount hermes-agent-src read-only into the
    WebUI service by default.
  • docker_init.bash prunes the agent source subtree from chown so read-only
    mounts do not break startup.
  • If an operator overrides the compose files with a mutable agent-source mount,
    startup now emits a notable warning. The WebUI still starts because local dev
    checkouts and custom deployments may intentionally be writable, but the warning
    makes the reduced boundary explicit.

Source-access inventory

These are the current WebUI capabilities that still rely on Agent source or
hermes_cli/agent modules being importable. Each item should eventually move
behind an explicit, versioned Agent API or a packaged library contract that does
not require mounting the live source checkout.

WebUI capability Current dependency Desired API / contract Notes
Browser chat execution run_agent.AIAgent imported by api/streaming.py Run lifecycle API: start, observe, status, cancel, approval, clarify, final usage Covered by the runtime-adapter migration in #1925, but still source-backed today.
Runtime event rendering WebUI callbacks around Agent token/reasoning/tool events Stable event envelope for tokens, reasoning, progress, tool lifecycle, approvals, clarify, errors, and final usage The existing run-adapter RFC describes the browser-facing shape; Agent still needs a durable producer contract.
Profile list/create/delete/seed hermes_cli.profiles from api/profiles.py Profile management API with profile metadata, env/runtime context, seed/delete operations, and validation errors WebUI has fallback filesystem handling for some operations, but feature parity follows Hermes CLI internals.
Goal command state hermes_cli.goals from api/goals.py Goal CRUD/control API: get, save, pause/resume/clear, and status Should preserve current /goal WebUI behavior without direct module import.
Slash command registry and plugin commands hermes_cli.commands and hermes_cli.plugins from api/commands.py Command/plugin capability discovery API scoped by active profile WebUI should render command help from a stable capability response.
Provider/auth/model catalogs hermes_cli.models, hermes_cli.auth, and agent.credential_pool from api/config.py Provider registry, model catalog, auth status, OAuth/credential-pool status APIs WebUI has static fallbacks, but exact parity and custom provider state come from Agent internals.
Redaction helper parity agent.redact.redact_sensitive_text from api/helpers.py Redaction service/library contract with signature/version compatibility WebUI keeps a fallback redactor because this import has changed before.
CLI/Gateway session bridge Agent state.db schema and gateway metadata read by sidebar/session helpers Session listing/transcript/metadata API for non-WebUI-originated sessions Direct SQLite/schema coupling should narrow over time, especially for messaging/email/gateway sessions.

Decoupling task list

  1. Keep the Docker default safe: WebUI-side hermes-agent-src stays read-only in
    two- and three-container compose files.
  2. Keep documenting the boundary honestly: multi-container isolates process,
    network, and resources, not filesystem/source compatibility.
  3. Warn loudly when the WebUI container sees a writable agent-source mount in
    Docker, because that weakens the defense-in-depth posture.
  4. Convert runtime execution first through the #1925 RuntimeAdapter path instead
    of adding new direct imports.
  5. For each inventory row, file or link a follow-up that defines the Agent API
    response shape before replacing the import.
  6. Do not claim the source mount can be removed until chat execution, provider
    catalogs/auth status, profiles, goals, commands/plugins, redaction, and
    imported Agent/Gateway sessions all have stable replacement contracts.

Non-goals for this slice

  • Do not remove HERMES_WEBUI_AGENT_DIR.
  • Do not break local source-checkout development.
  • Do not fail startup solely because the agent source is writable.
  • Do not replace the runtime adapter or Hermes Agent API in this document-only
    inventory slice.

Rfcs Canonical Session Resolution (`docs/rfcs/canonical-session-resolution.md`)

Canonical Session Resolution Contract

Problem

WebUI can reach the same conversation through several browser-facing entrypoints:

  • a URL route such as /session/<session_id>,
  • a query parameter such as ?session=<session_id> or ?session_id=<session_id>,
  • the browser's localStorage active-session value,
  • sidebar rows built from /api/sessions,
  • direct session open actions from links, search, or imported session lists,
  • browser boot restore after reload, auth redirect, or PWA resume.

After automatic compression, those entrypoints can point at different rows in one
logical conversation lineage. A pre-compression parent snapshot can remain a
valid archived session while the user-facing conversation tip has moved to a
newer continuation. If each caller resolves IDs independently, the UI can appear
to lose the session, reopen an old one-message snapshot, duplicate sidebar rows,
or prefer the wrong transcript even though durable data is still present.

This contract defines the expected resolution semantics for those entrypoints. It
is intentionally narrower than the run adapter RFC: this is about choosing the
correct visible session target, not moving execution ownership.

Goals

  • Define one canonical browser-facing resolution concept for sessions and
    compression lineage.
  • Make URL, query parameter, localStorage, sidebar, and direct-open behavior use
    the same mental model.
  • Preserve archived parent snapshots without letting them become the default
    active target when a continuation exists.
  • Give reviewers a small checklist for future session-routing, sidebar, and
    compression-lineage changes.

Non-goals

  • Do not delete archived pre_compression_snapshot rows.
  • Do not merge or rewrite session files as part of this contract.
  • Do not replace state.db/session sidecar reconciliation.
  • Do not require a new backend endpoint before narrow frontend guards can land.
  • Do not change explicit history browsing when the user deliberately opens an
    archived snapshot as a record.

Terms

Term Meaning
Requested session ID The ID supplied by route, query parameter, localStorage, sidebar click, or direct session open.
Canonical visible session The session row WebUI should display by default for normal chat navigation.
canonical_visible_session_id Proposed field/name for an API or helper output that identifies the canonical visible session.
Compression snapshot A preserved archived parent row with pre_compression_snapshot set.
Continuation session The active child/tip created after compression, usually represented by continuation_session_id, _lineage_tip_id, or newer lineage metadata.
Lineage relation Links such as parent_session_id, _lineage_root_id, _lineage_tip_id, and _compression_segment_count that connect rows belonging to one logical conversation.

Resolution Rules

  1. Directly valid non-snapshot IDs stay stable. If the requested session ID
    exists and is not a pre_compression_snapshot, it should normally resolve to
    itself.
  2. Snapshot parents defer to visible continuation tips. If the requested
    session ID is a pre_compression_snapshot and the session list has a newer
    non-snapshot continuation in the same lineage, normal chat navigation should
    resolve to that continuation as the canonical_visible_session_id.
  3. Explicit archive/history inspection remains possible. A future UI affordance
    may intentionally open a snapshot as a historical record, but that should be a
    distinct mode from ordinary boot restore, URL restore, or sidebar continuation.
  4. Local browser state is advisory. localStorage may remember the last active
    ID, but browser boot restore must treat it as a requested session ID and still
    run canonical resolution before rendering.
  5. Query aliases share the same resolver. ?session=..., ?session_id=...,
    and /session/... should feed the same requested-ID path instead of carrying
    separate precedence rules.
  6. Sidebar collapse and session loading agree. The row chosen as the visible
    representative for a lineage should match the target opened by loadSession()
    for that lineage during ordinary navigation.
  7. 404 self-heal is separate from lineage resolution. Missing/deleted sessions
    should still use the stale-route recovery path. Clear route and localStorage
    independently only while each still identifies the missing requested ID; a
    route-only 404 must preserve a different saved ID for the next boot restore.
    A present archived parent with a live continuation is not a 404; it is a
    canonicalization problem.

Entry Point Matrix

Entry point Input Expected resolution
URL route /session/ Treat `` as requested; resolve to canonical visible session before ordinary render.
Query parameter ?session= or ?session_id= Same as URL route. Query spelling must not change the target semantics.
localStorage last active session ID Advisory requested ID during browser boot restore; canonicalize before render.
Sidebar click visible row ID or lineage representative Open the same canonical visible session that the row represents.
Direct session open programmatic call/search/import link Use the shared requested-ID resolver unless the caller explicitly opts into archive inspection.
Browser boot restore URL and/or localStorage state after reload/auth/PWA resume Prefer explicit URL/query input, then localStorage, then canonicalize the requested ID.

Review Checklist

For PRs that touch session routing, compression lineage, sidebar collapse, boot
restore, direct session open, or URL parsing, answer:

  • Which entrypoints provide the requested session ID?
  • Does the code path accept both route and query parameter forms where relevant?
  • Does localStorage go through the same canonicalization path as URL restore?
  • Can a pre_compression_snapshot become the default active chat when a
    non-snapshot continuation_session_id / _lineage_tip_id exists?
  • Do sidebar collapse and loadSession() pick the same visible representative?
  • Is missing-session 404 recovery kept distinct from present-but-archived lineage
    canonicalization?
  • What regression proves route, query parameter, localStorage, and sidebar paths
    agree for compressed lineage rows?

Rollout Plan

  1. Document this proposed contract and link it from the public contract index.
  2. Keep narrow bugfixes small while referencing the relevant rule they preserve.
  3. Add shared frontend helper coverage for URL/query/localStorage/sidebar
    requested-ID inputs.
  4. If backend session APIs later expose canonical_visible_session_id, make the
    frontend resolver prefer the backend value while preserving client fallback for
    older WebUI servers.
  5. If #1925 moves execution/session ownership behind an adapter, carry this
    contract forward as an adapter-facing session-navigation invariant.

Rfcs Exact Persisted Message Replay Guard (`docs/rfcs/exact-persisted-message-replay-guard.md`)

Exact persisted-message replay guard

  • Status: Proposed
  • Author: @stefanpieter
  • Created: 2026-07-27

Problem

A WebUI display transcript can receive the same already-persisted assistant rows again during reconciliation. When those rows are replayed on successive turns, the sidecar grows approximately exponentially. This was observed in three sessions, including one with 591,595 rows from which a conservative repair retained 1,796 legitimate rows. The repeated rows carried the same stable message id, fractional timestamp, and complete payload.

The resulting sidecars and terminal done journals reached hundreds of megabytes to more than one gigabyte. Session loads, draft saves, stream-status reconstruction, and the watchdog then blocked on JSON I/O.

Decision

Session.save() is the final persistence boundary and MUST remove from its persisted snapshot only exact duplicate message dictionaries that carry both forms of stable event identity evidence: an exact built-in scalar id and an exact built-in scalar timestamp (falling back to _ts only when timestamp is absent or blank). Supported scalar types are str, int, and finite float; strings must be nonblank. Booleans, subclasses, containers, and non-finite numbers are not identity evidence, and scalar types remain distinct.

Exactness is defined by a strict canonical-JSON fingerprint (allow_nan=False) and complete dictionary equality inside a type-tagged (id, timestamp) bucket. The fingerprint keeps many enriched variants of one stable event linear while the equality check makes hash collisions fail closed. Rows with the same id but different timestamps, metadata, content, or scalar identity types remain distinct. If either the id or timestamp evidence is invalid, blank, whitespace-only, or absent, the row remains untouched even when its role/content match; repeated identical user or assistant text can be legitimate.

The first occurrence is retained and ordering of every retained row is preserved. Session.save() does not rebind or mutate the live messages list: it deep-snapshots and guards the rows locally, then uses that one guarded value for payload serialization, persisted message_count, shrink comparison, backup construction, and the sidebar-index entry. Nested dict/list values are detached too, so a retained row mutation after the scan cannot change the bytes authorized by an earlier deletion decision. This preserves appends and mutations that arrive through an existing list alias while a save is scanning; a later serialized save can persist them. Sidecar replacement and publication of its matching sidebar-index projection are one ordered operation under the existing reentrant index-writer lock, so an older overlapping save cannot publish its frozen index entry after a newer sidecar/index pair. A warning records the number of replay rows omitted from the persisted snapshot.

An unsupported top-level messages container (for example a dict or string) is preserved on load but rejected by Session.save() before any sidecar or index replacement; it is never normalized into an empty transcript. Recovery compares live and backup guarded transcript counts. A stale backup whose only excess is exact replay rows is therefore a no-op, while a backup with genuinely unique excess is atomically restored from the guarded payload with a matching message_count. Failure while guarding either valid parsed recovery candidate is an explicit non-restoring replay_guard_failed result; it must never be converted to an unreadable -1 count that lets a smaller stale backup replace a larger live transcript. When the existing shrink-backup safeguard runs, the sidecar snapshot's exact stable replay rows are removed before its normal atomic .bak replacement. This guard performs no additional post-save rewrite of .bak; outside that pre-existing shrink behavior, a backup remains byte-for-byte untouched, avoiding a new check-then-replace race with another writer.

Compatibility

  • No user-visible unique message is removed.
  • Repeated rows lacking stable identity are preserved.
  • Existing corrupted sessions require a one-time lossless repair; the guard prevents recurrence on later saves.
  • The guard is intentionally at the shared save boundary so every mutation path receives the same protection.

Verification

A regression test must prove that:

  1. separated exact copies with the same nonblank id and timestamp collapse;
  2. same-id rows with different timestamps or metadata survive;
  3. identical rows without both forms of stable identity survive;
  4. the persisted message_count matches the guarded message array;
  5. the sidebar index describes the same guarded transcript snapshot;
  6. a pre-existing backup remains byte-for-byte unchanged;
  7. malformed identities never authorize deletion and scalar identity types remain separate;
  8. an alias append that races the guard remains attached in memory and is persisted by a later save;
  9. nested retained-row mutations after the guard cannot alter the current save's scan-time bytes;
  10. dict/string top-level messages containers cannot be erased by a metadata save;
  11. manual and startup recovery ignore duplicate-only backup excess while restoring unique excess from a guarded, count-matched payload;
  12. overlapping saves cannot leave a newer sidecar paired with an older sidebar-index projection; and
  13. a replay-guard failure leaves live bytes unchanged and cannot authorize restoration of a smaller stale backup.

Rfcs Hermes Run Adapter Contract (`docs/rfcs/hermes-run-adapter-contract.md`)

Hermes Run Adapter Contract and Migration Gates

  • Status: Proposed
  • Author: @Michaelyklam
  • Updated by: @franksong2702
  • Created: 2026-05-11
  • Revised: 2026-05-23
  • Tracking issue: #1925

Credit and Scope

This RFC codifies the direction discussed in #1925. It does not introduce an
implementation. The central guardrail comes from Michael Lam's review framing:

the adapter should be a protocol translator, not a runtime surrogate.

The product boundary from #1925 is:

WebUI should be thin in execution ownership, not thin in product scope.

That means WebUI remains the full browser workbench for sessions, workspace
files, chat rendering, tool cards, approvals, status, diagnostics, and controls.
The change is that long-lived execution ownership should move behind an explicit
runtime boundary instead of remaining scattered through the main WebUI request
process.

This document is intentionally a reviewable spec and migration gate. It should be
accepted before any implementation PR for this adapter direction changes the
streaming hot path, introduces a runner process, or moves a new approval /
clarify / queue / goal control path. Narrow current-path bug fixes that do not
introduce a new runtime boundary can still proceed under the WebUI run-state
consistency contract and the relevant issue scope.

Problem

Browser-originated chat turns are still executed inside the WebUI server process.
The current path creates process-local stream state, starts background agent
threads, constructs or reuses AIAgent, and owns callback state for token, tool,
reasoning, approval, clarify, cancellation, and terminal events.

That shape works, but it makes the WebUI process the owner of active runtime
truth. Consequences include:

  • restarting WebUI can orphan active work,
  • reconnect depends on process-local state rather than a durable run/event view,
  • cancellation and stale writeback bugs recur around ownership boundaries,
  • approvals and clarify prompts are tied to live callbacks,
  • future Hermes runtime APIs cannot be adopted cleanly because WebUI lacks a
    single adapter boundary.

The immediate goal is not to build a sidecar. The immediate goal is to define the
browser contract, classify current runtime state, and gate the first reversible
journal slice.

Current Gate State — 2026-05-23

Slice 1 is now past the first active validation gate:

  • #2283 shipped the run-journal replay layer in v0.51.71.
  • A 100-trial synthetic replay/restart validation pass against current
    origin/master passed on 2026-05-16. The matrix covered completed-run replay,
    interrupted stale-pending recovery, fresh-pending grace handling, StreamChannel
    reconnect ordering, duplicate-prevention merge behavior, many-session recovery,
    large-journal derivation, and stream-to-turn-id lifecycle linking.
  • The focused regression set
    tests/test_turn_journal.py tests/test_turn_journal_lifecycle.py tests/test_stale_stream_pending_recovery.py
    also passed on the same worktree.
  • #2393, shipped through v0.51.76, capped live chat token SSE transports to the
    selected conversation pane. Background sessions now rely on existing
    status/replay/reattach behavior instead of keeping one live /api/chat/stream
    EventSource per active session.

This evidence did not prove the future runner/sidecar path. It did unblock the
adapter-seam work:

  • #2416 shipped the Slice 2 RuntimeAdapter seam contract.
  • #2424 shipped the default-off legacy-journal RuntimeAdapter seam in
    v0.51.81.
  • #2438 shipped the response-shape parity follow-up in v0.51.83, keeping the
    adapter flag from expanding /api/chat/start's public JSON contract.
  • #2469 shipped the Slice 3a cancel-control gate in v0.51.85.
  • #2479 shipped the first Slice 3a implementation in v0.51.86, routing Stop
    Generation through RuntimeAdapter.cancel_run(...) only when
    HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal is enabled.
  • #2487 shipped the Slice 3b approval/clarify gate, and #2496 shipped approval /
    clarify response routing through the adapter seam in v0.51.89.
  • #2509 shipped the Slice 3c queue/continue + goal gate in v0.51.90.
  • #2544 shipped the first Slice 3c implementation in v0.51.91. The goal
    route now uses RuntimeAdapter.update_goal(...) only when
    HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal is enabled, while preserving the
    legacy-direct response shape and leaving post-turn goal evaluation in the
    existing agent loop.
  • #2560 shipped the queue-staging clarification in v0.51.92. The RFC now treats
    queue_message(...) as a staged protocol method only; /queue remains
    browser-side queue/drain behavior, and no server-side queue endpoint or queue
    scheduler should be added merely for adapter symmetry.
  • #2575 shipped the Slice 4a runner/sidecar contract gate in v0.51.93.
  • #2599 shipped the Slice 4b RunnerRuntimeAdapter facade in v0.51.94. The
    facade normalizes an injected runner client's start / observe / status /
    control responses without owning AIAgent, streams, cancellation flags,
    approval queues, clarify queues, goal state, or cached-agent tables.
  • #2627 shipped the Slice 4c runner-backend harness gate in v0.51.96.
  • #2696 shipped the first Slice 4c implementation in v0.51.105: the default-off
    runner-local adapter selection point and build_runtime_adapter(...)
    factory wiring around an injected runner client. Live browser chat routes still
    stay on the legacy backend, and no supervised runner process exists yet.
  • #2744 shipped the Slice 4d supervised runner route gate in v0.51.108.
  • The next implementation slice is a default-off runner route-selection harness
    for /api/chat/start. It should only engage when runner-local is explicitly
    selected, return a bounded not-configured error until a supervised runner
    client exists, keep legacy-direct / legacy-journal fallback intact, pass
    explicit profile/workspace/model payloads instead of mutating WebUI process
    globals, and avoid recreating STREAMS / CANCEL_FLAGS / approval queues /
    clarify queues under new names.

The next gate is runner-backend plumbing, not queue implementation
by default. Queue / continue routing should only move before Slice 4 if a future
maintainer decision identifies an existing server-side legacy entry point and
pins its response shape, ordering, and idempotency contract. Otherwise, keeping
queue_message(...) staged is the honest boundary while execution ownership
moves out of the main WebUI request process.

Goals

  • Preserve the current rich WebUI workbench experience.
  • Make the browser-facing event/control contract explicit.
  • Classify every current runtime-owned state primitive as runner process,
    journal, adapter API surface, or WebUI presentation cache.
  • Identify future backend mapping: existing Hermes runtime API, missing Hermes
    API, or temporary WebUI compatibility shim.
  • Define acceptance tests that must survive any migration.
  • Define reversible implementation slices, starting with an append-only
    in-process event journal / replay layer.

Non-goals

  • Do not implement the adapter in this RFC.
  • Do not introduce a runner process or sidecar in the first implementation slice.
  • Do not change _run_agent_streaming control flow in the first journal slice.
  • Do not recreate STREAMS, cached AIAgent objects, callback queues, or
    cancellation flags under new names.
  • Do not reduce WebUI product scope or move normal workbench UX out of WebUI.
  • Do not depend on Hermes Agent shipping a WebUI-specific runtime connector before
    WebUI can improve its own boundary.

Artifact 1: Browser Event and Control Contract

This is the compatibility contract the browser depends on, regardless of whether
the backend is today's in-process streaming path, an in-process journaled path, a
future WebUI-managed runner, or a future Hermes /v1/runs backend.

The current inventory should be derived from static/messages.js consumers and
SSE/event production in api/streaming.py. Future edits to those files should
update this RFC or the implementation contract that replaces it.

Event Envelope

Every replayable runtime event should be representable with:

json
{
  "event_id": "run_123:42",
  "seq": 42,
  "run_id": "run_123",
  "session_id": "20260514_...",
  "type": "tool.updated",
  "created_at": 1778750000.0,
  "terminal": false,
  "payload": {}
}

Required semantics:

  • seq is monotonic per run.
  • event_id is stable enough to use as an SSE id: value or equivalent cursor.
  • Reconnect supports Last-Event-ID or after_seq.
  • Replay is at-least-once; WebUI deduplicates by run_id + seq or event_id.
  • Terminal runs can replay their final done, cancelled, or error state.

Event Families

Event family Required payload Browser responsibility Runtime source of truth
run.started / status lifecycle state, controls available, session id, workspace/profile/model/toolset summary render active state and controls runtime run state
token.delta assistant message id or segment id, delta text, content type append visible assistant text runtime model output stream
reasoning.delta / reasoning.done reasoning block id, delta/final text, visibility metadata render thinking/progress UI runtime reasoning events
progress concise phase/status text, optional tool context render activity/progress text runtime progress callbacks
tool.started tool call id, name, sanitized arguments, start time open/update tool card runtime tool lifecycle
tool.updated stdout/stderr/structured partial data, progress metadata update tool card runtime tool lifecycle
tool.done result, status/exit code, duration, error flag finalize tool card runtime tool lifecycle
approval.requested approval id, action summary, risk metadata, available choices show approval widget runtime approval state
approval.resolved approval id, choice, resulting status close/update approval widget runtime approval state
clarify.requested clarify id, question, choices/input mode show clarify widget runtime clarify state
clarify.resolved clarify id, answer metadata/status close/update clarify widget runtime clarify state
title.updated title text, source/confidence update title surfaces session/title subsystem
usage.updated / usage.final tokens, cost, model/provider, duration where available update usage surfaces runtime usage accounting
error stable error code, safe message, redacted diagnostics, terminal flag render error and final state runtime terminal/error state
done final lifecycle state, usage, terminal result/error summary, last seq finalize run UI runtime terminal state

Reconnect Metadata

Every active or terminal run must expose:

  • run_id
  • session_id
  • status: queued, running, awaiting_approval, awaiting_clarify,
    paused, cancelling, cancelled, failed, completed, or expired
  • last committed event cursor / last_event_id
  • terminal state and final result/error when finished
  • currently available controls
  • pending approval/clarify ids, if any
  • session-to-active-run mapping for the current WebUI session

Controls

Control Required semantics Target owner
observe attach to live events and replay from cursor adapter API surface backed by runtime/journal
status poll lifecycle state when SSE/WebSocket is unavailable adapter API surface backed by runtime/journal
cancel request graceful cancellation; terminal event follows runner/runtime control plane
queue / continue append follow-up work according to Hermes semantics runner/runtime control plane
approval resolve pending approval by id with supported choices runner/runtime control plane
clarify answer pending clarify request by id runner/runtime control plane
goal set/status/pause/resume/clear goal where capability exists runtime command/capability plane

WebUI may keep presentation state such as expanded rows, selected tabs, and local
scroll position. WebUI must not privately mutate runtime truth for these controls.

Artifact 2: Runtime State Inventory and Classifier

Classifications:

  • runner process: should be owned by the eventual execution runner / runtime
    backend, not the main WebUI request process.
  • journal: should be captured in append-only durable events for replay and
    diagnostics.
  • adapter API surface: should be exposed through a WebUI-owned boundary that
    can later switch backend implementations.
  • WebUI presentation cache: may remain local because it is not execution truth.
Current primitive Current legacy source of truth Target classification Future backend mapping Slice 1 handling Notes / gap
STREAMS / STREAMS_LOCK api.state_sync process memory adapter API surface + presentation fan-out WebUI runner or future Hermes run observation API keep live path; mirror events into journal Must stop being authoritative for active run existence.
CANCEL_FLAGS api.state_sync process memory runner process cancel/interrupt endpoint or runner control no control-flow change Final cancel state must return as a replayable event.
cached AIAgent objects / AGENT_INSTANCES api/config.py process memory runner process runner-owned Hermes integration unchanged Moving this is deferred until after journal proof.
background thread lifecycle _run_agent_streaming in api/streaming.py runner process runner-owned execution lifecycle unchanged Slice 1 must not rewrite thread/control flow.
token / partial text buffers streaming callbacks and browser SSE state journal + presentation cache replayable runtime events append emitted events Browser can cache rendered state, but replay must rebuild it.
reasoning buffers streaming callbacks and UI rendering state journal + presentation cache replayable reasoning events append emitted events Thinking cards must survive reconnect.
tool buffers / live tool calls WebUI streaming callbacks journal + presentation cache replayable tool lifecycle events append emitted events WebUI owns rendering, not tool execution state.
approval callbacks / queues live Python callbacks runner process + adapter API surface + journal approval state/control endpoint journal request/resolution events only Pending approval must eventually survive WebUI restart.
clarify callbacks / queues live Python callbacks runner process + adapter API surface + journal clarify state/control endpoint journal request/resolution events only Pending clarify must eventually survive WebUI restart.
per-request HERMES_HOME env mutation lock api/streaming.py / config helpers runner process runner/profile execution context unchanged Long-term runner must isolate profile env without process-global mutation.
session-to-active-run mapping session JSON + active stream ids + memory journal + adapter API surface runtime run registry/session mapping journal run metadata Reopen session must discover active/completed run.
title generation state WebUI callbacks/session saves journal + presentation cache runtime/session title event append title events WebUI may display title updates after event receipt.
usage accounting state WebUI callbacks/session saves journal + presentation cache runtime usage event/source of truth append usage events Avoid divergent WebUI-only accounting.
command capability metadata WebUI command registry + Hermes command assumptions adapter API surface runtime command/capability metadata unchanged Unknown command support should not be guessed by WebUI.
voice mode state browser/UI + streaming path presentation cache + adapter API surface runtime input/control capability unchanged Acceptance tests must pin voice behavior before migration.
project/workspace context WebUI session/workspace state + env mutation adapter API surface + runner process runtime run context unchanged Must preserve workspace-aware chat and project context.

Unclassified state is a design blocker. If an implementation slice discovers a
runtime primitive that does not fit this table, update the RFC before landing code.

Artifact 3: Acceptance Test Catalog

These are the user-observable behaviors that must survive the migration. The
catalog should become automated tests where practical. Where full automation is
not feasible in the first slice, the PR must include the strongest practical
diagnostic or manual validation plan.

Behavior Acceptance criterion Why it matters First slice that must prove it
Journal replay after refresh/reconnect reconnect or restart after events have been journaled can replay from cursor without duplicate transcript/tool/reasoning state proves the browser contract is replayable and duplicate-safe journal/replay slice
Terminal replay completed/failed/cancelled runs replay terminal state and do not duplicate transcript content prevents stale spinner and duplicate-message regressions journal/replay slice
Interrupted/stale run diagnostics if WebUI restarts while execution is still owned by the WebUI process, replay shows the last journaled state and a clear interrupted/stale diagnostic instead of pretending the run kept executing keeps slice 1 honest before a runner exists journal/replay slice
Execution survives WebUI restart active execution outlives the main WebUI process, reconnect discovers the active run, ordered replay catches up, and controls such as cancel still work proves execution ownership actually moved out of the request process runner/sidecar or external-runtime slice
Cancel during tool call cancel emits one terminal cancelled state and no stale writeback catches historical stream ownership races control migration slice
Cancel during reasoning partial/reasoning content is preserved cleanly and final state is not provider-error catches cancellation classification regressions control migration slice
Approval request/response approval survives observation, browser response reaches runtime, result is replayable approval callbacks are cross-cutting and easy to orphan approval migration slice
Clarify request/response clarify survives observation, browser response reaches runtime, result is replayable same risk as approval, different UI/control path clarify migration slice
Slash commands /compress, /branch, /retry, and other supported commands keep current semantics command behavior should not be reimplemented ad hoc command capability slice
Model switch mid-session provider/model changes route through the correct runtime context prevents provider/source-of-truth drift adapter control slice
Workspace context run receives the session workspace and attachments context preserves workbench value adapter control slice
Multi-profile isolation profile-specific runs write/read the correct Hermes home and memory protects #2134-family isolation concerns runner/profile slice
Queue/continue follow-up input during live/resumable work obeys Hermes semantics prevents parallel continuation model control migration slice
Goal continuation goal status/control survives the adapter boundary goal logic is lifecycle-sensitive goal capability slice
Voice mode voice-originated input uses the same run/event/control contract prevents alternate input path drift adapter parity slice
Projects context project metadata remains visible and correct across run replay preserves session/workbench organization adapter parity slice

Artifact 4: Slicing Plan and Reversibility

Slice 0: Spec PR

Scope:

  • this RFC update,
  • no runtime behavior change,
  • no streaming hot-path code change.

Revert path: revert the docs PR.

Slice 1: Append-only journal/replay beside the legacy path

Pre-authorized only after this spec is reviewed and accepted in #1925.

Scope:

  • add an append-only event journal alongside existing callback paths,
  • capture the event families in Artifact 1,
  • persist run metadata, cursor, terminal state, and safe diagnostic fields,
  • allow reconnect to replay from a cursor and then continue live observation,
  • keep _run_agent_streaming control flow unchanged,
  • keep cancellation, approval, clarify, queue, and goal behavior unchanged.

Non-goals:

  • no runner process,
  • no sidecar,
  • no adapter interface that changes control flow,
  • no replacement of STREAMS as the live delivery path,
  • no speculative rewrite of agent construction/caching.

Revert path:

  • disable journal writes/replay behind one small integration seam,
  • retain legacy WebUI streaming path unchanged.

Success criterion:

  1. Start a non-trivial WebUI run.
  2. Refresh/reconnect the browser, or restart WebUI after events have already been
    journaled.
  3. Rediscover the run from journal metadata.
  4. Replay from cursor without duplicate visible transcript content.
  5. Render the same already-journaled token/reasoning/tool/status/terminal state
    the workbench would have rendered without the reconnect.
  6. If WebUI restarted while execution was still owned by the WebUI process, show
    an explicit interrupted/stale diagnostic rather than claiming the active run
    kept executing.

Slice 2: Adapter interface over the journaled legacy path

Status as of 2026-05-17: shipped. PR #2416 defined the adapter-seam contract,
PR #2424 added the default-off LegacyJournalRuntimeAdapter seam, and PR #2438
kept /api/chat/start response shape identical between legacy-direct and the
flagged legacy-journal path. Slice 2 remains a reversible boundary change, not
a sidecar or execution-ownership move.

Scope:

  • introduce the RuntimeAdapter interface only after Slice 1 proves replay,
  • implement the first backend as a thin facade over the still-legacy path plus
    journal,
  • keep the browser event contract stable,
  • keep controls routed to existing code until a later control-specific slice.

Revert path: switch the feature flag back to direct legacy path.

Slice 2 interface contract

The Slice 2 seam should introduce a deliberately small RuntimeAdapter boundary
without changing the execution backend. The first implementation is a
LegacyJournalRuntimeAdapter that delegates to the existing WebUI-owned
streaming path and reads the Slice 1 journal for status/replay. That makes the
adapter a protocol translator over the current backend, not a new runtime owner.

Minimal interface shape:

python
class RuntimeAdapter:
    def start_run(self, request: StartRunRequest) -> RunStartResult: ...