Files
hermes-agent/web
teknium1 2e497bead8 fix(dashboard): close the indirection holes and the unnamed-profile 400s
Round 2 of the REST profile-scope pass. A call-graph audit (AST over every
router plus their imported helpers, following functools.partial bindings,
closures and callbacks handed to to_thread/_spawn_job) found the writes a
signature grep cannot see, and the SPA/Desktop callers that never named a
profile at all.

Handlers that reached a config write through indirection:
* PUT /api/dashboard/plugin-providers wrote memory.provider + context.engine
  through functools.partial(_write_config_value, ...) — the SAME key
  PUT /api/memory/provider scopes — into the launch profile's config.yaml.
* POST /api/local-models/quickstart is `activate` plus a download; only
  `activate` had been scoped, so _set_runtime_enabled/_assign_default landed
  in the launch profile.
* POST /api/local-models/runtime/install regenerates launch presets under
  get_hermes_home().
* GET /api/model/recommended-default lazily PERSISTS discovered custom-provider
  models via build_models_payload -> _save_discovered_models_to_config.

Policy gaps:
* POST /api/ops/hooks is now gated like DELETE: writing an arbitrary command
  into `hooks:` and, with approve, into the consent allowlist is strictly more
  privileged than removing one.
* POST /api/sessions/prune (non-dry-run), DELETE /api/sessions/empty and
  POST /api/sessions/bulk-delete join the same destructive class.
* PUT /api/memory/provider ran its readiness check OUTSIDE the scope, so it
  judged the launch profile and could write a broken setting into another.

Callers:
* /api/credentials was missing from the SPA's PROFILE_SCOPED_PREFIXES, so the
  credential-pool delete button 400'd unconditionally; so were
  /api/dashboard/plugin-providers, /api/local-models and the model route above.
* The management scope was empty until the switcher resolved, so every
  destructive route 400'd in working UI on any host with a second profile
  directory. The backend now injects the profile it itself serves
  (__HERMES_DASHBOARD_PROFILE__) — and only when that name provably resolves
  back to its own home, so the fallback can never retarget another profile.
* authedFetch went around the scope entirely; the Desktop /api/ops callers
  (doctor, security-audit, backup, debug-share) sent no profile while Electron
  pins that family to the shared primary backend.
2026-09-21 09:59:11 -07:00
..
…
…

Hermes Agent — Web UI

Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.

Stack

  • Vite + React 19 + TypeScript
  • Tailwind CSS v4 with custom dark theme
  • shadcn/ui-style components (hand-rolled, no CLI dependency)

Development

# Start the backend API server
cd ../
python -m hermes_cli.main web --no-open

# In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/
npm install
npm run dev

Open the Vite URL printed in the terminal (usually http://localhost:5173). That is the live-reload UI.

hermes dashboard on port 9119 serves the built bundle from hermes_cli/web_dist/, not the Vite dev server — changes in web/src/ will not appear there until you run npm run build and restart the dashboard (or use web --no-open + Vite as above).

The Vite dev server proxies /api requests to http://127.0.0.1:9119 (the FastAPI backend).

Build

npm run build

This outputs to ../hermes_cli/web_dist/, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via pyproject.toml package-data.

Structure

src/
├── components/ui/   # Reusable UI primitives (Card, Badge, Button, Input, etc.)
├── lib/
│   ├── api.ts       # API client — typed fetch wrappers for all backend endpoints
│   └── utils.ts     # cn() helper for Tailwind class merging
├── pages/
│   ├── StatusPage   # Agent status, active/recent sessions
│   ├── ConfigPage   # Dynamic config editor (reads schema from backend)
│   └── EnvPage      # API key management with save/clear
├── App.tsx          # Main layout and navigation
├── main.tsx         # React entry point
└── index.css        # Tailwind imports and theme variables

Typography & contrast rules

Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of.

Text size floor

  • Minimum body size: text-xs (12px / 0.75rem). Do not use arbitrary text-[0.6rem], text-[0.65rem], text-[9px], text-[10px], or text-[11px] on copy, hints, labels, counts, or badges. Use the standard scale: text-xs, text-sm, text-base.
  • Smaller sizes are only acceptable on decorative overlays (chart stripes, empty-state icons) — never on text the user is meant to read.

Opacity floor on text

  • Never apply opacity below 0.7 to text. No opacity-30, opacity-50, opacity-60 on <span>s, <p>s, labels, etc.
  • Do not stack opacity tokens. Patterns like text-muted-foreground/60, text-midground/70, text-foreground/50 create unpredictable WCAG failures because the parent token already has alpha.
  • Use the semantic text tokens from @nous-research/ui's globals.css:
    • text-text-primary — default body text.
    • text-text-secondary — subtitles, meta, inactive nav.
    • text-text-tertiary — small chrome labels, counts, footnotes.
    • text-text-disabled — disabled states.
    • text-text-on-accent — text on filled accent surfaces.

Brand uppercase via text-display, not raw uppercase

  • The dashboard preserves the Nous brand uppercase aesthetic, but it is opt-in per element, not global.
  • Apply uppercase via the DS utility text-display on brand chrome only — page titles, nav section headings, badges, brand wordmark. DS components (Button, Badge, Tabs, Segmented, etc.) already self-apply text-display.
  • Do not introduce new uppercase (the literal Tailwind class) in hermes-agent/web/src. Prefer text-display for new brand chrome. Legacy uppercase call sites (e.g. components/ui/label.tsx, card.tsx) remain until migrated.
  • The app shell no longer forces uppercase globally, so blanket normal-case opt-outs are unnecessary. Use normal-case only where a DS component applies text-display but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) or fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”).

Fonts

Typography is opt-in per surface, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set.

Tier Classes Use for
Brand chrome font-mondwest text-display (or themedChrome) Sidebar nav, card section headers (CardTitle), Segmented filter buttons, filter panel headings
Themed body font-mondwest normal-case (or themedBody) Card content (Card, CardDescription), session/platform rows, analytics tables — scoped to the component
Page chrome font-expanded Page header h1 (PageHeaderProvider) — sentence case, not text-display
Wordmark Typography + size/tracking only Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no text-display
Technical font-mono-ui / font-mono / font-courier Model slugs, env keys, schedules, YAML, repo URLs
  • Do not put themedBody or themedFont on <main>, App, or other layout wrappers — it overrides component-scoped styles.
  • Card applies themedBody; CardTitle uses text-display (uppercase chrome); CardDescription uses themedBody.
  • NouiTypography defaults to font-sans unless a font prop is passed.
  • Do not use raw font-sans or font-display (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate.

Color tokens

  • Prefer semantic tokens (text-text-*, bg-card, border-border, text-foreground, text-destructive, text-success, text-warning) over raw layer references (text-midground, text-foreground).
  • text-muted-foreground is now wired to --color-text-secondary, so existing call sites stay correct, but new code should prefer the semantic name.
  • When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at ≥ 0.7 for any text.