1 · Empty state

## First load / no messages

Renders inside `#messages` when `S.messages` is empty. Logo + title + subtitle + 3 suggestion buttons.

.empty-state
H

## What can I help with?

Ask anything, run commands, explore files, or manage your scheduled tasks.

2 · User messages

## Right-aligned bubble, attachments, and edit mode

User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a `.msg-foot` below the bubble, revealed on hover (forced visible here).

.msg-row[data-role="user"] — plain

How do I run the dev server and point it at a specific workspace path?

10:42
.msg-files — attachments above body (right-aligned)
📎 architecture-notes.pdf 📎 Q1-forecast.xlsx 📎 meeting.docx 📎 screenshot.png

Please review these docs and summarise the key decisions.

.msg-edit-area + .msg-edit-bar — edit mode
How do I run the dev server and point it at a specific workspace path — and can I do it without docker?
3 · Assistant — markdown basics

## Paragraphs, emphasis, lists, blockquote, hr, links

Assistant output is a single `.msg-row.assistant-turn` that holds one role header + an `.assistant-turn-blocks` column of one-or-more `.assistant-segment` children. Each segment may contain a `.thinking-card`, a `.msg-body`, and its own `.msg-foot` (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.

.msg-body — rich prose
H Hermes

# Running the dev server

You can start Hermes with the built-in launcher. The simplest path is no docker, no proxy — the CLI handles everything.

## Prerequisites

- Node `>= 18`
- A workspace directory you own
  - Read/write permissions
  - No existing `.hermes` folder
- An API key set via `HERMES_API_KEY`

## Steps

1. Clone the repo
2. Run `npm install`
3. Start with `npm run dev -- --workspace ~/code`

> Tip: the `--workspace` flag accepts absolute or `~`-prefixed paths. Relative paths are resolved against the CWD.

For full setup options see the configuration guide.

.msg-body table
H Hermes

Model comparison:

Model | Context | Good for | Cost / 1M in
--- | --- | --- | ---
Opus 4.6 | 1M | Deep reasoning, long code | `$15.00`
Sonnet 4.6 | 1M | Daily driver, agents | `$3.00`
Haiku 4.5 | 200k | Fast tasks, tool loops | `$0.80`

4 · Code blocks

## Plain, with header, with copy button, multi-language

pre + code (no header)
H Hermes

    npm install
    npm run dev -- --workspace ~/code

.pre-header + pre + .code-copy-btn
H Hermes
typescript

    export async function startServer(opts: ServerOptions) {
      const port = opts.port ?? 3000;
      const app = createApp();
      app.listen(port, () => {
        console.log(`Hermes listening on :${port}`);
      });
      return app;
    }

python

    from hermes import Agent

    def main() -> None:
        agent = Agent(model="claude-opus-4-6")
        reply = agent.run("Summarise today's commits")
        print(reply)

    if __name__ == "__main__":
        main()

json

    {
      "model": "claude-sonnet-4-6",
      "stream": true,
      "tools": ["bash", "edit_file", "search"]
    }

5 · Inline media

## Images (default & zoomed) and downloadable links

.msg-media-img (default + .msg-media-img--full)
H Hermes

Here's the screenshot you asked for (click to zoom):

And the full-width variant:

.msg-media-link — non-image downloads
H Hermes

I saved the generated files:

📎 report-2026-Q1.pdf 📎 revenue.csv 📎 diagram.svg

6 · Math & diagrams

## KaTeX inline / block & Mermaid block

.katex-inline + .katex-block
H Hermes

Inline math: \(E = mc^2\) and the quadratic formula below:

$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$

A tidier form: \(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\).

$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$
.mermaid-block (pre-render placeholder)
H Hermes

The request flow:

    graph LR
      U[User] --> C[Composer]
      C --> API[/api/chat/]
      API --> M((Model))
      M --> T{tool?}
      T -- yes --> X[Tool Runner]
      T -- no  --> R[Reply]
      X --> R
      R --> U

7 · Thinking / reasoning

## Bordered panel (collapsed / open, animated), live loader, streaming cursor

Thinking cards are rendered at the top of an `.assistant-segment`. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a `max-height` + opacity transition. Click the header in either example below to see the animation live.

.thinking-card (collapsed, inside .assistant-segment)
H Hermes
💡 Thought for 4.3s ▶

    The user asked about the dev server...

Here's the shortest path…

.thinking-card.open (animated — max-height + opacity)
H Hermes
💡 Thought for 4.3s ▶

    The user is asking about launching the dev server.
    Options: npm script, docker, or the bundled CLI.
    The CLI is the simplest — no container runtime needed.
    I should show the exact commands and the --workspace flag,
    then mention the env var for the API key at the end.

Here's the shortest path…

.thinking — live 3-dot loader (pre-reasoning)
H Hermes
Thinking
[data-live-assistant="1"] — streaming cursor at end of last child
H Hermes

Sure — the simplest way is to run `npm run dev`. The CLI will pick up the default

8 · Tool cards

## Running, done, expanded, subagent, error, multi-card toggle

Tool cards sit in `.tool-card-row` wrappers (no longer nested under `.msg-row`). The details panel now animates open/closed via `max-height` + opacity — click any header below to see the transition.

.tool-card.tool-card-running (collapsed, pulsing dot)
⚡ bash npm run build ▶
.tool-card — done, collapsed
📄 read_file static/style.css · 1155 lines ▶
.tool-card.open — args table + result snippet + Show more (animated detail)
⚡ bash grep -rn "msg-role" static/ · exit 0 · 380ms ▶
command: grep -rn "msg-role" static/
cwd: /Users/aron/hermes-webui
timeout: 30000

    static/style.css:430:  .msg-role{font-size:12px;font-weight:500...}
    static/style.css:431:  .msg-role.user{color:rgba(124,185,255,0.65);}
    static/style.css:432:  .msg-role.assistant{color:rgba(201,168,76,0.6);}
    static/ui.js:1141:    const roleEl = el('div', 'msg-role ' + role);

.tool-card.tool-card-subagent — delegated work
🤖 Subagent Explore · Map chat messages UI elements ▶
🤖 Delegate task Plan · Propose redesign variants ▶
.tool-card (error snippet)
⚡ bash npm run typecheck · exit 1 · 2.3s ▶
command: npm run typecheck

    src/server.ts:42:7 - error TS2345: Argument of type 'string | undefined'
      is not assignable to parameter of type 'number'.

    42       app.listen(opts.port, () => {
             ~~~~~~~~~

.tool-cards-toggle — Expand/Collapse All (≥2 cards)
📄read_file package.json▶
🔎grep"listen" in src/▶
⚡bash npm run typecheck · exit 0 · 4.1s▶
9 · Meta affordances

## Role timestamp tooltip, footer action toolbar, token-usage badge

Assistant timestamps live on the `.msg-role` `title` attribute (hover for full date). Copy/regen buttons sit in the per-segment `.msg-foot`, 45% opacity at rest, full on turn hover. The `.msg-usage` badge is always visible at the bottom of the turn.

Full hover state — .msg-foot actions + .msg-usage
H Hermes

Built and type-checked successfully — server is running on `:3000`.

3.2K in · 481 out · ~$0.012
10 · Full composition

## User turn → assistant turn (segment 1: thinking + body + tool cards) → usage

A realistic turn: one role header up top, then the segment hosting a thinking card plus the first body; tool cards follow as siblings of the turn inside `.messages-inner`; the usage badge closes the turn.

All-in-one turn
📎 server.ts

The build fails — can you type-check and explain?

10:40
H Hermes
💡Thought for 2.1s▶

    Attached server.ts — probably typing issue.
    Run typecheck to confirm, then patch.

The build fails because `opts.port` can be `undefined`. Two fixes below — pick the one that matches your intent.

### Option A — require the port

    export function startServer(opts: { port: number }) {
      app.listen(opts.port);
    }

### Option B — default to 3000

    export function startServer(opts: { port?: number } = {}) {
      const port = opts.port ?? 3000;
      app.listen(port);
    }

I ran the checks below to confirm.

11.4K in · 612 out · ~$0.049
📄read_file src/server.ts · 58 lines▶
path: src/server.ts

    export function startServer(opts: ServerOptions) {
      app.listen(opts.port, () => { ... });
    }

⚡bash npm run typecheck · exit 1 · 2.3s▶
✏️edit_file src/server.ts +1 / -1▶
11 · Bubble layout

## Opt-in via `body.bubble-layout` — extra bubble padding for assistant too

The default layout already right-aligns user messages (the redesign adopted it globally), so this toggle mostly affects additional padding / boundary handling. Flip the Bubble layout toggle in the header to see the mode applied.

Conversation sample

Can you add a retry button next to the regenerate one?

H Hermes

Yes — it can share `.msg-action-btn` and live in the same `.msg-actions` container. I'll wire it up on `_lastError`.

Perfect, go for it.

12 · System / inline notes

## Compression, cancellation, errors — rendered as italicised assistant messages

Italic system notices (still italic — info, not errors)
H Hermes

[Context was auto-compressed to continue the conversation]

Task cancelled.

.assistant-segment[data-error="1"] — real error card, red accent, no italic
H Hermes

Error: Connection lost. Your last message was saved — refresh to continue.

Error: Upstream rate-limited (429). Retrying in 30s…

12b · Turn boundaries & date separators

## Right-alignment separates user turns · day-change separator

The dashed divider before each user turn was removed — the right-edge bubble alignment is its own visual break, so only a small vertical gap (10px top margin) remains between turns. Day changes still get a centred `.msg-date-sep`.

.msg-date-sep — Today / Yesterday / weekday / date
Yesterday

Can you summarise the PR I opened earlier?

H Hermes

Yes — three files changed, net +42 / -18. Main change is the new rail variable…

Today

Did CI pass overnight?

H Hermes

All green — three jobs, 4m 12s total. Here's the breakdown:

13 · Overlay cards (adjacent to transcript)

## Approval & Clarify cards + reconnect banner

.approval-card — 4 button variants (once / session / always / deny)
⚠ Approval required
The agent wants to run a shell command in `/Users/aron/hermes-webui`.
rm -rf node_modules && npm install
.clarify-card — choice buttons + free-text fallback
? Clarification needed
Which environment should I deploy this to?
Pick a choice, or type your own answer below.
Reconnect / mid-stream recovery banner
⚠ A response may have been in progress when you last left. Reload messages?
⚠ Agent run exited with non-zero status (code 1). Check the logs.
14 · Structure & data-attribute cheat sheet

## Wrappers and state markers produced by `renderMessages()`

### Wrappers

- `.msg-row[data-role="user"]` — one user turn (right-aligned bubble, 60% max-width)
- `.msg-row.assistant-turn[data-role="assistant"]` — one assistant turn; contains one `.msg-role` and one `.assistant-turn-blocks`
- `.assistant-turn-blocks` — flex-column holder for segments
- `.assistant-segment` — a single logical chunk inside a turn: optional `.thinking-card` + optional `.msg-body` + optional `.msg-foot`
- `.assistant-segment-anchor` — hidden segment kept as a DOM anchor for tool cards when the model emitted no text
- `.tool-card-row` — per-tool-card wrapper, sibling of the turn inside `.messages-inner`
- `.msg-foot` — per-segment (or per-user-row) footer holding `.msg-time` + `.msg-actions`

### Data attributes & IDs

- `data-role="user|assistant"` — role marker on the row
- `data-msgIdx="N"` — index into `S.messages`; on user rows and assistant segments
- `data-raw-text="…"` — plain-text source for copy (now lives on `.assistant-segment` for assistant output)
- `data-live-assistant="1"` — the segment that's currently streaming
- `data-editing="1"` — row is in edit mode
- `data-error="1"` — error state; applies to `.msg-row` (user) or `.assistant-segment`
- `id="liveAssistantTurn"` — on the turn that contains the streaming segment
- `.tool-card-row[data-live-tid="…"]` — live tool-call card (removed when the turn settles)
- `data-mermaid-id`, `data-katex`, `data-rendered` — block rendering state