Hermes WebUI: The best way to use Hermes Agent from the web or from your phone!
# Hermes Web UI: Developer and Architecture Guide
> This document is the canonical reference for anyone (human or agent) working on the
> Hermes Web UI. It covers the exact current state of the code, every design decision and
> quirk discovered during development, and a phased architecture improvement roadmap that
> runs in parallel with the feature roadmap in ROADMAP.md.
>
> Keep this document updated as architecture changes are made.
> Current shipped build: `v0.51.792` (July 1, 2026).
> Automated coverage: ~11,500 tests via `pytest tests/ --collect-only -q`. CI runs on
> Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff
> lint gate, a headless browser smoke test, and a Docker smoke test.
>
> Notable architecture state: the bootstrap and first-run onboarding flow own setup discovery; the default WebUI state directory is `~/.hermes/webui`; `ctl.sh` provides a daemon wrapper for homelab installs; chat streaming is still WebUI-owned SSE with stream-ownership guards, cancellation, async manual compression, and turn-journal audit plumbing; provider/model discovery is profile-aware with live-model cache invalidation and custom-provider scoping. (Version/test-count numbers above are a periodic snapshot β the authoritative source is the latest git tag and `pytest --collect-only`.)
---
## 1. Overview and Purpose
The Hermes Web UI is a lightweight web application that gives you a browser-based
interface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on
the Claude-style interface: a sidebar for session management, a central chat area,
and a demand-driven right panel used for workspace browsing and preview surfaces.
The right panel is closed by default on desktop and opens only when it is actively
being used for browsing or previewing content.
To prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the
saved workspace panel state into `document.documentElement.dataset.workspacePanel`
before the main stylesheet loads. Desktop CSS honors that preload marker immediately,
and `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.
The design philosophy is deliberately minimal. There is no build step, no bundler, no
frontend framework. The Python server is split into a routing shell (server.py) and
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
This makes the code easy to modify from a terminal or by an agent.
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
control-center modal for global preferences, conversation import/export, and clear-conversation
actions. The topbar remains focused on conversation context and the workspace/files toggle.
---
## 2. File Inventory
<repo>/
server.py Thin routing shell + HTTP Handler + auth middleware.
Delegates all route handling to api/routes.py.
bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open.
start.sh Thin wrapper around bootstrap.py for shell-based startup.
ctl.sh Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.
pyproject.toml Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh.
Dockerfile python:3.12-slim container image
docker-compose.yml Compose config with named volume and optional auth
.dockerignore Excludes .git, tests/, .env* from Docker builds
api/
__init__.py Package marker
agent_compat.py Resolver for Hermes Agent names moved to sibling modules (compatibility-only)
auth.py Optional password authentication, signed cookies, passkeys/WebAuthn
config.py Discovery, globals, model detection, reloadable config
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers
goals.py Persistent-goal commands and profile-scoped native GoalManager bridge
models.py Session model + CRUD, per-session profile tracking, CLI/state.db bridge
profiles.py Profile state management, hermes_cli wrapper
onboarding.py First-run onboarding status, real provider config writes, OAuth linking, readiness detection
routes.py All GET + POST route handlers (if/elif dispatch, no decorators)
startup.py Startup helpers: auto_install_agent_deps()
state_sync.py state.db bridge β opt-in /insights usage/title sync; always mirrors the session workspace into sessions.cwd
streaming.py SSE engine, run_agent, cancel, compression, HERMES_HOME save/restore
updates.py Self-update check and release notes
upload.py Multipart parser, file upload handler
workspace.py File ops: list_dir, read_file_content, git detection, workspace helpers
static/
index.html HTML template
style.css All CSS incl. mobile responsive, themes + skins, KaTeX
ui.js DOM helpers, renderMd, tool cards, context indicator, file tree
workspace.js File preview, file ops, git badge, central api() fetch wrapper
sessions.js Session CRUD, list rendering, collapsible groups, search, SSE sync
messages.js send(), SSE event handlers, approval/clarify, transcript, recovery
panels.js Cron, skills, memory, profiles, todo, settings (Control Center)
commands.js Slash command registry, parser, autocomplete dropdown
boot.js Event wiring, mobile nav, voice input, theme/skin boot, bfcache handler
onboarding.js First-run wizard overlay, provider setup flow
i18n.js Localization catalog (en, es, de, zh, zh-Hant, ru, β¦)
login.js Login page + open-redirect guard
icons.js Lucide icon path registry
sw.js Service worker: offline shell cache, version-pinned assets
tests/
conftest.py Isolated test server/state fixtures
~1,150 test files ~11,500 tests collected via pytest (run `pytest --collect-only -q` for exact)
test_regressions.py Permanent regression gate
CONTRIBUTING.md Contributor workflow and PR expectations.
ROADMAP.md Feature and product roadmap document.
SPRINTS.md Forward sprint plan with CLI + Claude parity targets.
ARCHITECTURE.md THIS FILE.
TESTING.md Manual browser test plan and automated coverage reference.
CHANGELOG.md Release notes per version.
CONTRIBUTORS.md Community credit roll (regenerated via the maintainer workspace script).
requirements.txt Python dependencies.
.env.example Sample environment variable overrides.
> Per-file line counts intentionally omitted β they drift every release. Use
> `git ls-files | xargs wc -l` (or your editor) for current sizes; the role of
> each file above is the durable part.
State directory (runtime data, separate from source):
~/.hermes/webui/
sessions/ One JSON file per session: {session_id}.json
workspaces.json Registered workspaces list
last_workspace.txt Last-used workspace path
settings.json User settings (default model, workspace, send key, password hash)
projects.json Session project groups (name, color, id)
Log file:
~/.hermes/webui/bootstrap-8787.log start.sh/bootstrap background server log
~/.hermes/webui.log ctl.sh daemon log
---
## 3. Runtime Environment
### Context replay matching
`api/streaming.py::_strip_replayed_context_items` filters replayed model-context
blocks using longest-match statistics from a suffix automaton over reversed
replay keys. Its greedy walk still removes only blocks of at least three rows;
short repeats, row order, and provider-facing `api_content` identity are preserved.
Long arc-summary identity is normalized once per row and indexed by role, provider
payload and the existing 1,500-character prefix rule. Malformed unhashable roles
retain an equality-scan fallback. For ordinary hashable roles/keys, matching uses
O(existing + candidate) key operations and auxiliary space, in addition to payload
normalization. All indexes are call-local; no session cache, durable schema, writer
ownership, or SSE contract changes. Inputs are not mutated and retained rows are
returned unchanged. All three context-reconciliation branches share this helper.
- Python interpreter: <agent-dir>/venv/bin/python
- The venv has all Hermes agent dependencies (run_agent, tools/*, cron/*)
- Server binds to 127.0.0.1:8787 (localhost only, not public internet)
- Access from Mac: SSH tunnel: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>
- The server imports Hermes modules via sys.path.insert(0, parent_dir)
Suffix/prefix replay matching uses a linear prefix-function scan and constructs
each comparison key once, preserving the existing replay equality contract.
Read-side stale-stream maintenance defers when the session writer lock is busy;
it leaves stream ownership and pending input intact for a later repair pass.
Environment variables controlling behavior:
HERMES_WEBUI_HOST Bind address (default: 127.0.0.1)
HERMES_WEBUI_PORT Port (default: 8787)
HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions
HERMES_WEBUI_STATE_DIR Where sessions/ folder lives
HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml
HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard
HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)
HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
Test isolation environment variables (set by conftest.py):
HERMES_WEBUI_TEST_PORT=... Optional pinned test port
HERMES_WEBUI_TEST_STATE_DIR=/tmp/hermes-webui-tests/* Optional pinned test state (default: OS temp dir; must be outside ~/.hermes)
HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace Isolated test workspace
Tests NEVER talk to the production server (port 8787).
The test state dir is wiped before each test session and deleted after.
See: <repo>/tests/conftest.py
Per-request environment variables (set by chat handler, restored after):
TERMINAL_CWD Set to session.workspace before running agent.
The terminal tool reads this to default cwd.
HERMES_EXEC_ASK Set to "1" to enable approval gate for dangerous commands.
HERMES_SESSION_KEY Set to session_id. The approval tool keys pending entries
by this value, enabling per-session approval state.
HERMES_HOME Set to the active profile's directory before running agent.
Saved and restored around each agent run.
WARNING: These env vars are process-global. Two concurrent chat requests will clobber
each other. This is safe only for single-user, single-concurrent-request use.
See Architecture Phase B for the fix.
---
## 4. Server Architecture: Current State
### 4.1 HTTP Server Layer
Python stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in its own
thread. The Handler class subclasses BaseHTTPRequestHandler with two methods:
do_GET Routes: /, /health, /api/session, /api/sessions, /api/list,
/api/chat/stream, /api/file, /api/approval/pending,
/api/session/worktree/status
do_POST Routes: /api/upload, /api/session/new, /api/session/update,
/api/session/delete, /api/chat/start, /api/chat,
/api/approval/respond, /api/session/worktree/remove
Routing is a flat if/elif chain inside each method. No routing framework.
Helper functions used by all handlers:
j(handler, payload, status=200) Sends JSON response with correct headers
t(handler, payload, status=200, ct) Sends plain text or HTML response
read_body(handler) Reads and JSON-parses the POST body
CRITICAL ORDERING RULE in do_POST:
The /api/upload check MUST appear BEFORE calling read_body(). read_body() calls
handler.rfile.read() which consumes the HTTP body stream. The upload handler also
needs rfile (to read the multipart payload). If read_body() runs first on a multipart
request, the upload handler receives an empty body and the upload silently fails.
### 4.2 Session Model
Session is a plain Python class (not a dataclass, not SQLAlchemy):
Fields:
session_id hex string, 12 chars (uuid4().hex[:12])
title string, auto-set from first user message
workspace absolute path string, resolved at creation
model model ID string (e.g. "anthropic/claude-sonnet-4.6")
messages list of OpenAI-format message dicts
created_at float Unix timestamp
updated_at float Unix timestamp, updated on every save()
pinned bool, default False (Sprint 12)
archived bool, default False (Sprint 14)
project_id string or null, FK to projects.json (Sprint 15)
tool_calls list of tool call dicts (Sprint 10)
Key methods:
path (property) Returns SESSION_DIR/{session_id}.json
save() Writes __dict__ as pretty JSON to path, updates updated_at
load(cls, sid) Class method: reads JSON from disk, returns Session or None
compact() Returns metadata-only dict (no messages) for the session list
In-memory cache:
SESSIONS = {} dict: session_id -> Session object
LOCK = threading.Lock() defined but NOT currently used around SESSIONS access
get_session(sid): checks SESSIONS cache, loads from disk on miss, raises KeyError
new_session(workspace, model): creates Session, caches in SESSIONS, saves, returns
all_sessions(): scans SESSION_DIR/*.json + SESSIONS, deduplicates, sorts by updated_at,
returns list of compact() dicts
all_sessions() does a full directory scan on every call.
With 10 sessions: negligible. With 1000+: will be slow.
See Architecture Phase C for the index file fix.
title_from(): takes messages list, finds first user message, returns first 64 chars.
Called after run_conversation() completes to set the session title retroactively.
#### Session transcript reconciliation with `state.db`
`reconciled_state_db_messages_for_session()` uses
`merge_session_messages_append_only()` to combine a WebUI sidecar or context
projection with active Agent `state.db` rows. Its explicit
`incoming_provenance="state_db"` fence permits a state-only row whose timestamp
predates the sidecar tail to use a safe chronological slot. Paginated
`msg_limit` consumers rely on this merged order directly rather than applying a
later timestamp sort.
The same merge helper also stitches ordered child sidecars onto archived
compression parents. Those calls leave incoming provenance unverified, so their
stable message sequence remains append-only even when parent rows carry later,
restamped timestamps. Timestamp alone is never authority to move a continuation
inside its parent transcript.
If an older row could only be placed before the first surviving sidecar/context
row, the insertion helper declines it to avoid resurrecting compacted history.
Reconciliation then appends that row instead of dropping it; rows without a
usable timestamp and rows at or after the sidecar tail also append normally.
The fallback therefore preserves an accepted state-only row when exact ordering
is ambiguous, while safely placeable recovery rows remain chronological.
#### Imported `state.db` sidebar projection
`api.models.get_cli_sessions()` projects conversations from the active Hermes
profile's `state.db` into sidebar-shaped rows. The default projection keeps
interactive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions)
in a bounded 20-row candidate window. Background sources use independent recovery
passes so a high-volume worker source cannot consume that interactive window:
- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows.
- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows.
- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows.
- One-shot (`hermes -z`): kept out of the interactive window and its
unassigned refill, with no default pass of its own. They keep normal project
chips, so the assigned recovery pass still reaches one filed under a project.
An explicit `source_filter="oneshot"` lists the newest ones, and a session
the user opens by id resolves through `get_cli_session_by_id()`.
Source-specific views still use their dedicated bounds, and the later sidebar
visibility stage decides whether recovered background rows are shown. In
`all_profiles=True` mode the per-profile source bounds are disabled before rows
are merged; cross-profile scoping, visibility, deduplication, and final route
limits remain downstream responsibilities.
#### Compression lineage and session-list invalidation
`api.agent_sessions._is_continuation_session()` is the shared classifier for
sidebar projection, lineage metadata/reporting, and `state.db` transcript
stitching. It uses the direct parent link, no conflicting non-empty source,
`compression` or `cli_close` parent end reason, and the existing two-second
`started_at` overlap allowance. A `source="tool"` child is always a separate
conversation, even when its parent is also a tool session or has no source.
A direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the
child's `model_config` also makes a boundary; inherited ancestor markers do
not. Malformed or unverifiable marker evidence fails closed as a boundary.
The overlap allowance is the existing master policy, not a new window set by
this change.
The gateway watcher's cheap database fingerprint includes `model_config`, so
a marker-only update causes a fresh projection. Its published-payload hash
covers every emitted session field, not just ID, activity time and message
count; a changed projected title or lineage field can therefore emit
`sessions_changed` even without message-row churn.
### 4.3 SSE Streaming Engine
This is the most architecturally interesting part. Two endpoints cooperate:
POST /api/chat/start Receives the user message. Creates a queue.Queue, stores it
in STREAMS[stream_id], spawns a daemon thread running
_run_agent_streaming(), returns {stream_id} immediately.
GET /api/chat/stream Long-lived SSE connection. Reads from STREAMS[stream_id]
and forwards events to the browser until 'done' or 'error'.
Queue registry:
STREAMS = {} dict: stream_id -> queue.Queue
STREAMS_LOCK = threading.Lock()
SSE event types and their data shapes:
token {"text": "..."} LLM token delta
tool {"name": "...", "preview": "..."} Tool invocation started
approval {"command": "...", "description": "...", "pattern_keys": [...]}
done {"session": {compact_fields + messages}} Agent finished successfully
error {"message": "...", "trace": "..."} Agent threw exception
The SSE handler loop:
- Blocks on queue.get(timeout=30)
- On timeout (no events in 30s): sends a heartbeat comment (": heartbeat
")
to keep the connection alive through proxies and firewalls
- On 'done' or 'error' event: breaks the loop and returns
- Catches BrokenPipeError and ConnectionResetError silently (browser disconnected)
Stream cleanup: _run_agent_streaming() pops its stream_id from STREAMS in a finally
block. If the browser disconnects mid-stream, the daemon thread runs to completion and
then cleans up. The queue fills and the put_nowait() calls fail silently (queue.Full
is caught).
Fallback sync endpoint: POST /api/chat still exists and holds the connection open until
the agent finishes. The frontend never uses it but it can be useful for debugging.
### 4.4 Agent Invocation (_run_agent_streaming)
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id):
1. Fetches session from SESSIONS (not from disk -- session was just updated by /api/chat/start)
2. Sets TERMINAL_CWD, HERMES_EXEC_ASK, HERMES_SESSION_KEY env vars
3. Creates AIAgent with:
- model=model, platform='cli', quiet_mode=True
- enabled_toolsets=CLI_TOOLSETS (from config.yaml or hardcoded default)
- session_id=session_id
- stream_delta_callback=on_token (fires per token)
- tool_progress_callback=on_tool (fires per tool invocation)
4. Calls agent.run_conversation(user_message=msg_text, conversation_history=s.messages,
task_id=session_id)
NOTE: keyword is task_id NOT session_id (common mistake, documented in skill)
5. On return: updates s.messages, calls title_from(), saves session
6. Puts ('done', {session: ...}) into queue
7. Finally block: restores env vars, pops stream_id from STREAMS
on_token callback:
if text is None: return # end-of-stream sentinel from AIAgent
put('token', {'text': text})
on_tool callback:
put('tool', {'name': name, 'preview': preview})
# Also immediately surface any pending approval:
if has_pending(session_id):
with _lock: p = dict(_pending.get(session_id, {}))
if p: put('approval', p)
The approval surface-on-tool logic means approvals appear immediately after the tool
fires (within the same SSE stream), without waiting for the next poll cycle.
### 4.5 Approval System Integration
The approval system uses the existing Hermes gateway module at tools/approval.py.
All state lives in module-level variables in that file:
_pending = {} dict: session_key -> pending_entry_dict
_lock = Lock() protects _pending
_permanent_approved set of permanently approved pattern keys
Because server.py imports tools.approval at module load time and everything runs in the
same process, this state IS shared between HTTP threads and agent daemon threads.
Important: this only works because Python imports are cached (sys.modules). The same
module object is used everywhere. If the approval module were ever imported in a subprocess
or via importlib.reload(), this would break.
GET /api/approval/pending:
- Peeks at _pending[sid] without removing it
- Returns {pending: entry} or {pending: null}
- Called by the browser every 1500ms while S.busy is true (polling fallback)
POST /api/approval/respond:
- Pops _pending[sid] (removes it)
- For choice "once" or "session": calls approve_session(sid, pattern_key) for each key
- For choice "always": calls approve_session + approve_permanent + save_permanent_allowlist
- For choice "deny": just pops, does nothing (agent gets denied result)
- Returns {ok: true, choice: choice}
### 4.6 File Upload Parser
parse_multipart(rfile, content_type, content_length):
- Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)
- Extracts boundary from Content-Type header
- Splits raw bytes on b'--' + boundary
- For each part: parses MIME headers via email.parser.HeaderParser
- Returns (fields, files) where fields is {name: value} and files is {name: (filename, bytes)}
handle_upload(handler):
- Calls parse_multipart()
- Validates: file field present, filename present, session exists
- Sanitizes filename: replaces non-word chars with _, truncates to 200 chars
- Writes bytes to session.workspace / safe_name
- Returns {filename, path, size}
Why not cgi.FieldStorage:
- Deprecated in Python 3.11+
- Broken for binary files (silently corrupts or throws)
- The manual parser handles all file types correctly
### 4.7 File System Operations
safe_resolve(root, requested):
- Resolves requested path relative to root
- Calls .relative_to(root) to assert the result is inside root
- Raises ValueError on path traversal (../../etc/passwd)
list_dir(workspace, rel='.'):
- Calls safe_resolve, then iterdir()
- Sorts: directories first, then files, case-insensitive alpha within each group
- Returns up to 200 entries with {name, path, type, size}
read_file_content(workspace, rel):
- Calls safe_resolve
- Enforces MAX_FILE_BYTES = 200KB size limit
- Reads as UTF-8 with errors='replace' (binary files show replacement chars)
- Returns {path, content, size, lines}
### 4.8 Persistent Goal Profile Boundary
`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook.
Hermes Agent's native `GoalManager` is the authoritative owner of goal evaluation,
continuation decisions, wait barriers, failure counters, contracts, subgoals, and
`state.db` persistence.
For a profile-scoped WebUI session, the bridge delegates only when the Agent exposes
both the context-local `set_hermes_home_override()` API and call-time default
`SessionDB` path resolution. The bridge probes the resolved default path under the
selected context before constructing the native manager, then binds that profile's
Hermes home before every native call. The override is reset in a `finally` block after
every operation, so concurrent sessions using the same session ID under different
profiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the
same scoped native persistence path.
Older Hermes Agent versions that lack either capability continue through
`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit
`state.db` path. This includes intermediate versions whose context API is present but
whose default `SessionDB()` path remains frozen at module import. Keep this fallback
compatibility-only: new goal semantics belong in Hermes Agent's native manager rather
than a second WebUI implementation.
### 4.9 Hermes Agent Moved-Name Compatibility
Hermes Agent owns its module layout. Its September 2026 decomposition moved names the
WebUI uses (for example `tools.approval.set_current_session_key` to
`tools.approval_context`) into `<stem>_<topic>` sibling modules. The old paths resolve
only through temporary PLUGIN-COMPAT `__getattr__` pointers that emit
`HermesPluginCompatWarning` and are removed on schedule. The Agent's
`compat_manifest.json` is the authoritative map of what moved where.
WebUI code reaches a moved name only through
`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this
order:
1. the owner module's own namespace: pre-split Agents, and tests that stub the original
module in `sys.modules` or patch the name onto it;
2. the `home` module: split Agents, with or without the old-path pointer (no warning);
3. plain attribute access on the owner: non-module test doubles.
It raises like the import it replaces (or returns `default`), so each call site keeps its
existing fallback. Current users: approval session identity and MCP discovery
(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude
Code credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and
kanban connections and dispatch (`kanban_bridge.py`).
Import names that are still native to their module directly. Never
`from <old module> import <moved name>`, and never feature-detect a moved name with
`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn
"moved" into "missing" behind the call sites' broad `except` blocks. When a later Agent
split moves another name, route it through `agent_attr` and add pre-split and
pointer-removed cases to `tests/test_agent_compat.py`. The resolver is
compatibility-only: delete it, and import directly from the new homes, once the WebUI
stops supporting Agents that predate the split.
### 4.10 MCP Runtime Profile Boundary
Hermes Agent owns the in-process MCP ledger. It keys a connection by
`(profile_home_key, name)` and registers its tools in that profile's registry overlay
only when the calling task serves a *routed* profile: the context-local Hermes-home
override differs from the process home. Otherwise the connection uses the bare server
name and the global registry slot, which belong to the process profile.
- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:
startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go
through it, so `get_process_profile_home()` and the Agent pin
(`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated
in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into
`os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a
turn's own profile look like the process profile, so same-named servers of different
profiles share one bare-name connection.
- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run
their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the
request profile's home and secret scope (root profile included) without touching
`os.environ`. Status, tool count and inventory are read from one scope; the inventory
lists only tools in that profile's own registry slot for servers it configures.
- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else
the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared
connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for
status, inventory and the reload summary. The Agent's launch-profile view (scope
`None`) is process-wide, so `filter_runtime_status_to_view()` downgrades
`get_mcp_status()` rows of servers the root profile does not serve to `configured`
rather than showing a routed profile's connection, tool count or connect error.
- The scope is trusted only when the Agent's routing decision matches the profile WebUI
resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an
Agent without the pin), status and inventory withhold runtime data
(`runtime_scope: "unavailable"`, shown as a notice in the MCP panel) and
`/reload-mcp` refuses instead of resetting another owner's connection.
- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's
own scope and the names of its live connections; `scope=None` without `names` is the
process-wide wildcard and is used only with Agents that predate profile-scoped MCP
(`runtime_scope: "legacy_process"`). A scoped shutdown only clears connect backoff for
the live keys it tears down, so WebUI also drops the profile's own cooldown/error
entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed
to spawn is retried by the reload, as the wildcard did, without touching another
owner's backoff.
Status and inventory stay passive: they never start or probe an MCP server. Ledger key
helpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape
has one owner; the local fallbacks only cover Agents that predate that module.
#### MCP configuration writes
MCP create/update, toggle and delete are separate from Agent runtime ownership.
Each edit resolves the existing `_get_config_path()` once under `_cfg_lock`,
reads a private raw YAML mapping, and commits to that same path with the existing
atomic writer. `HERMES_CONFIG_PATH` keeps its documented precedence; this does
not redirect operator-configured files or change profile resolution.
Raw `${ENV_VAR}` references, unrelated configuration and masked credentials keep
their original stored values. YAML-aliased containers and the selected server
entry are detached before mutation so an edit cannot change a sibling or template. Runtime-expanded cache dictionaries are never the
write source. An unreadable, malformed or non-mapping existing document aborts
without overwriting it; a missing/empty document can be initialized. Failed disk
writes do not publish uncommitted mutations into the runtime cache. Reloading
that cache and sending HTTP responses happen after the lock is released.
This transaction serializes cooperating writes/reloads in one WebUI process.
It does not lock out other processes or provide Agent status/schema/dispatch
isolation; those remain the runtime boundary described above.
### 4.11 Model Catalog Rebuild Budget and Probe Scheduling
The model picker's catalog (`GET /api/models`) is produced by one cold rebuild that
live-probes providers. A probe is a network call to a provider the WebUI may not be able
to reach β a LAN LM Studio/Ollama host is the usual case, because the probe runs in the
server process rather than the browser β so the rebuild runs on a daemon worker under a
wall-clock budget:
| Knob | Default | Meaning |
| --- | --- | --- |
| `HERMES_WEBUI_MODELS_REBUILD_BUDGET` | `4` (seconds) | Window a foreground caller waits for a cold rebuild. `0` restores the legacy synchronous unbounded rebuild. |
| `CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS` | `5.0` | Per-endpoint cap for a custom provider `/v1/models` probe. |
| `CUSTOM_MODELS_ATTEMPT_ONLY_TIMEOUT_SECONDS` | `0.01` | Attempt-only timeout for a probe reached after the window is already spent while the caller is still waiting. Not a per-probe minimum and not a slice β see below. |
Within the window the rebuild behaves normally; past it the caller is served a fallback
(last-known disk cache, else a network-free static catalog) while the worker keeps going so
the refresh lands out-of-band for the next caller.
Custom endpoints are probed **serially**: the active `model.base_url` first, then each
named `custom_providers` entry in config order, then the LM Studio provider-group
fallback. `_CustomProbeSchedule` in `api/config.py` hands each of those probes a fair
slice of the *remaining* window instead of letting one probe take the whole per-endpoint
cap, so an unreachable endpoint cannot leave the reachable providers behind it with no
in-band probe at all (#7481).
- Each slice is `min(cap, left / (remaining + 0.1))`, with **no lower bound**. The fractional
slot reserves headroom without halving a lone target's window. Unused time is lent to
later probes; the matching active/LM Studio target does not reserve a duplicate slot.
Wall-clock overhead can still cross the foreground deadline. A floor would make
the chain's cumulative spend grow with the endpoint count, and `custom_providers` has no
count limit, so a long enough chain of dead endpoints could again outspend the window and
push the reachable providers behind it out of the in-band rebuild.
- The window belongs to the **caller, not to the chain**. One absolute deadline is captured
before the worker and the foreground wait start and is handed to `_CustomProbeSchedule`;
the foreground waits only `max(0, deadline - now)`. A schedule that minted its own deadline
at construction β deep inside the worker, after provider detection, the live id lookups and
the profile rebind β charged that discovery work to the caller's wait *and* granted it to
the chain again, so the chain could still be probing after the caller had already been
served the over-budget fallback, and the reachable provider landed out-of-band at best.
- The two states that keep the unthrottled per-endpoint cap are stated explicitly rather
than inferred from the clock, because a spent window says nothing about whether anyone is
still waiting:
- the legacy unbounded path (budget `0`), which has no window to share;
- the out-of-band continuation β the worker still probing after the foreground caller
gave up and served its fallback. It is recognised through an explicit signal (the
caller sets the event when it stops waiting) and is the only in-process state allowed
to spend past the window. The narrow race where a probe finds the window spent while
the caller *is* still waiting is not that state: it gets the attempt-only timeout
above, never the cap.
- A slice can truncate healthy endpoints even in short chains. Such timeouts are not
reported as unreachable and the partial catalog is never cached in memory or on disk.
The same worker retries truncated targets once at the full endpoint cap after the
foreground returns, retaining successful probes and publishing the complete catalog
through the existing generation, source-identity, ownership and durable fences.
- An endpoint is probed **once per rebuild, except for that full-cap retry**. The three live-probe consumers
(active `model.base_url`, named `custom_providers`, LM Studio provider-group fallback)
share a per-rebuild memo keyed by the complete request URL (path slashes and query
preserved) plus the Authorization header sent, so the
common config where the active endpoint and `providers.lmstudio.base_url` are the same
LAN host no longer pays that host's connect timeout twice, and two named entries on one
endpoint probe it once. A different URL β or the same URL with a different key, which can
change the outcome β is still its own probe, and the memo is consulted only after the
per-endpoint SSRF/authentication checks, so a call that would have been refused still is.
- Probe order, per-endpoint SSRF and authentication rules, and the per-endpoint cap are
preserved.
Publication is ordered by **generation, not by wall clock**. Each cold rebuild takes the
next sequence number (`_allocate_models_rebuild_seq`), and a result is dropped when it is
older than the newest *allocated* generation (`_models_rebuild_seq`) β in which case it also
leaves the build flag alone rather than clearing it, because that flag now belongs to the
newer rebuild. This matters for the out-of-band publisher, which outlives the
foreground caller: without the guard it could resurrect a superseded
catalog over a newer one. A timestamp comparison cannot express the ordering, because an
older build can publish *after* a newer build has already started.
The fence is the latest **allocated** generation rather than the latest *published* one
because `invalidate_models_cache()` clears `_cache_build_in_progress` without cancelling an
in-flight worker: a newer rebuild can be allocated while an older one is still running. If
the older worker finishes first, a published-sequence comparison would let it publish the
invalidated catalog and release the flag that now belongs to the newer rebuild. Comparing
against the allocated generation rejects it, so invalidation is a real freshness boundary,
and a newer rebuild that then fails leaves the cache empty instead of resurrecting the
invalidated catalog.
Both `invalidate_models_cache()` and the provider-scoped invalidator used by
`/api/models/refresh` share one epoch/owner reset. They advance the allocated generation,
clear memory and provenance, evict the relevant credential pools before admission reopens,
retire the in-flight owner, and delete the durable cache.
Every build already running becomes superseded *even when no successor rebuild is ever
allocated*; otherwise a delayed worker could repopulate the cleared catalog under a
fresh-looking fingerprint. Disk snapshots read before taking the catalog lock (including
the session-visit warmer) are published only if their captured epoch still matches: a
preloaded fresh candidate whose epoch moved is dropped rather than published under the
current fingerprint. The *degraded* stale fallback is deliberately not fenced β it is
returned to a caller that already stopped waiting and is never published to memory, so
discarding it would only swap a stale-but-real answer for a static catalog.
A build's **identity is captured when it starts and re-validated when it publishes**, in
memory and at the durable commit: the source fingerprint (`_models_cache_source_fingerprint`
β config, auth store and provider catalog; its `config_yaml` axis is the profile-specific
config path, so it fences a foreign profile too) plus the profile name. A build that outlives
a config edit describes sources that no longer exist, so its result is dropped instead of
being published under the *current* fingerprint. The check fails closed β an identity that
cannot be read is not a match β and disk publication records the fingerprint the build READ,
never a recomputed one.
Ownership of the single-flight slot is explicit too: the completion path clears
`_cache_build_in_progress` only when the completing build is still the newest allocated
generation, including on the error paths and after the disk I/O. A superseded publisher
therefore cannot admit a third rebuild beside the newer one it lost to.
The **durable commit is fenced separately from the in-memory one**, because the file write
runs with the catalog lock released (a disk write must not hold it). Each build writes its
own temp file β unique per build and thread, in the destination directory so the commit stays
a same-filesystem atomic rename β and the rename is serialized by
`_models_cache_disk_commit_lock` after re-checking the accepted generation, the last
committed generation (`_models_disk_committed_seq`) and the build's source identity.
The catalog lock remains held from that check **through** rename; invalidation takes the
same fixed order (disk-commit lock, then catalog lock) through deletion, so an admitted
writer cannot restore a file after invalidation returns. A superseded or re-sourced
commit discards its temp file and leaves the durable catalog to the build that won.
**The two locks have one order everywhere: disk-commit then catalog.** The writer and the
invalidator both take them that way, so a caller that takes them in the opposite order is a
deadlock, not a latency bug β and both acquisitions are unbounded in production. A foreground
publisher used to: `get_available_models` owns the catalog lock across its whole cold path,
and its synchronous, within-budget and budget-boundary winners called
`_save_models_cache_to_disk` from inside that ownership, i.e. catalog β commit. A publisher
blocked on the commit mutex an invalidator held, with that invalidator then blocked on the
catalog lock the publisher owned, waited on each other forever, so a `/api/models` load and a
config-save/`/api/models/refresh` invalidation could each hang (#7481 review). The foreground
paths therefore do not commit inline: the catalog lock still spans the whole cold path,
including the foreground wait and memory publication (owner, source identity and the
absolute deadline are read there too). Only the durable commit moves outside: it is
**queued** and run by
`_DeferredCatalogPublication` on its way out β after the catalog lock has been released β by
`_commit_models_cache_to_disk_after_lock`. The caller still gets the cache and the durable
file populated before `get_available_models` returns, and the single-flight release rides
with the queued commit, so ownership still spans the durable write. The out-of-band worker
commits directly, which is correct because it holds no catalog lock by then.
### 4.12 Insights Period Endpoint (`/api/insights`)
`GET /api/insights` reports usage analytics for one time window. The window is
selected by query parameters, and the response echoes the window it actually
served through `mode` (`"trailing"` or `"custom"`). Totals and the daily series
are always computed over the same resolved interval. Data comes from the WebUI
session index plus non-WebUI rows in the active profile's `state.db`, when that
database exists. The optional `sync_to_insights` setting mirrors WebUI usage
into `state.db`; it does not control whether this endpoint includes CLI rows.
Window selection:
- `days=N` β trailing window of the last `N` days. This is what the panel's
7/30/90/365 presets send, and the fallback when no absolute bound is usable.
- `start=β¦` / `end=β¦` β an absolute window. Each bound is either an exact Unix
epoch timestamp in seconds or a `YYYY-MM-DD` calendar date. A supplied
`start` with no `end` runs to the server clock. A supplied `end` with no
`start` back-steps the start by 30 days using the bound's own arithmetic: a
`YYYY-MM-DD` end subtracts 30 calendar days (local midnight to local
midnight, DST-safe), while a numeric end subtracts exactly `30 * 86400`
seconds β so across a daylight-saving change a numeric end's default start can
land on a different local date than the calendar one would.
- The **Custom rangeβ¦** selection sends the two `<input type=date>` values as
raw `YYYY-MM-DD` strings, not epoch seconds, so the server reads them in its
own timezone and a browser in another timezone cannot shift the selected
calendar day. The panel defaults the inputs to the last 30 calendar days only
when no range has been picked yet, so preset β Custom never erases a chosen
range.
Bounds and fallbacks:
- A `YYYY-MM-DD` bound selects a whole local calendar day (start = that day's
local midnight; an end date's exclusive stop is the next local midnight,
except a date equal to today, which clamps to the server clock because that
day is not over). An epoch bound keeps its exact `[start, end)` precision.
Bounds are swapped if supplied reversed.
- The window never extends into the future: an `end` beyond the server clock
is clamped to `now`.
- Windows are clamped to five calendar years so the daily series cannot grow
unbounded.
- The request fails closed to the trailing `days` window (`mode: "trailing"`)
when an absolute bound cannot be served faithfully: a supplied bound that is
unparseable (e.g. `2026-02-31`) or out of the supported epoch range, a
pre-epoch date (Chromium's date input accepts `0001-β¦`, and no session
predates 1970), or a strictly-future start. A supplied-but-invalid bound is
never treated as an omitted one, so a rejected request cannot fabricate an
interval the caller did not ask for. An explicit zero-length window pinned to
the clock itself (`start == now`) stays an empty custom interval rather than
silently serving the trailing window.
### 4.12 Project Bindings (`/api/projects/bind`)
A session project can carry **bindings**: a set of workspaces (one of them the
default), a model, and β API-only since the dialog row is hidden and no session
applies it yet β a stored reasoning effort. The chip's quick-create (`+`) button, and the top-level New Chat button
while that project filter is active, open a new session already configured for
the project's context. Right-click
a project chip and pick **Project settingsβ¦** to edit them: one row in the chip
menu (no inline summary, no per-axis unbind rows), a dialog built on the shared
`.app-dialog*` classes (skins, Escape, focus trap) that stays below the shared
app dialog's z-index so a prompt it opens stacks on top, and a menu clamped to
the viewport.
`POST /api/projects/bind` updates a single project by `project_id`. Every body
field is optional and only the supplied ones are touched:
- `workspaces: [str]` β replaces the full bound list. Each entry passes the
same trusted-path check as `/api/session/new` (`validate_workspace_to_add`
then `resolve_trusted_workspace`) and is auto-registered in the saved
workspace list, so a path outside the default root can be bound in one step.
Entries are de-duplicated, keeping first-seen order. `null` clears the list
(and the default with it).
- `default_workspace: str` β the workspace a new session starts in. It must be
a member of `workspaces`; a value outside the list is auto-added so the
invariant "`default_workspace` β `workspaces`" always holds. Removing the
workspace that was the default also drops the default.
- `workspace: str` β the legacy single-workspace field from before
multi-workspace bindings. It is still accepted and kept in sync as
`workspaces: [w]`, so pre-existing rows that only set `workspace` keep
working; `_project_workspaces` reads the list first and falls back to it.
- `auto_assign: bool` β accepted and **stored but dormant**. The backfill
sweep it used to drive, the dialog toggle that set it and the
workspace-keyed filing of new sessions were split out into a follow-up PR, so
this build reads the stored value nowhere. It is shape-checked (a JSON
boolean) in the request pre-flight, like `reasoning_effort`, and `null`/`false`
still clears it.
- `model` / `model_provider` / `reasoning_effort` β single-value bindings per
project; `null` clears that axis (the project is then unbound on it). The
dialog renders the model row only: it never submits `reasoning_effort`, so
saving a binding PRESERVES an existing effort value instead of clearing it β
and, since the UX re-gate of 2026-10-08, no session applies that stored value
either (see below).
`reasoning_effort` is **stored but dormant**. `/api/projects/bind` still accepts
it (and `null` still clears it), and the dialog still renders no row for it
(re-gate 2026-10-07) β but since the UX re-gate of 2026-10-08 nothing forwards
it into a new session, so