# Admin UI reference

A compact map of the admin web app: every `/api/admin/*` mount, every
sidebar surface, and the operator-facing widgets that read host or session
state. The deep architecture lives in [`platform.md`](platform.md) (UI
layout, session reconcile, route lifecycle) and
[`admin-session.md`](admin-session.md) (the session-cookie / PIN-rebind /
SDK-resume contract). This file is the index that points at them.

## Scope and tree decision

The `maxy-code/` tree does **not** ship a `.docs/platform.md` developer
doc. The legacy root tree (`getmaxy/`) carries one at
`.docs/platform.md` for the original Maxy installer; the Maxy Code tree
keeps its architecture surface in two places:

- `maxy-code-prd.md` at the repo root — product requirements and the
  source of truth for every task in `.tasks/`.
- `platform/plugins/docs/references/platform.md` — operator-facing
  architecture, loaded by the docs plugin at session start.

Anything that would have gone into `maxy-code/.docs/platform.md` belongs
in one of those two files instead. `maxy-code/.docs/` itself is
reserved for vertical / integration notes (LinkedIn extension,
PropertyData, Real Agent standalone, MCP server inventory) — not for
core-platform docs.

## Admin Hono routes

Every admin sub-app is mounted by
[`platform/ui/server/routes/admin/index.ts`](../../../ui/server/routes/admin/index.ts)
under a per-area prefix. The outer `requireAdminSession` middleware
runs in `server/index.ts` before the aggregator; individual handlers
re-apply `requireAdminSession` where they need a resolved `senderId`.

`/actions` and `/version` are **not** mounted here — they live on
`maxy-edge.service` via `server/edge-admin.ts` so the upgrade view
survives the mid-run restart of the brand service. Double-mounting
either is a regression.

### Sessions and chat

| Mount | Purpose | Key methods |
|---|---|---|
| `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
| `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
| `/sidebar-sessions` | Sole data path for the sidebar Sessions list (Tasks 538 + 543). One JSONL on disk equals one row. The row's delete button (Task 543) is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone (Task 543) — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. | `GET /` |
| `/session-delete` | POST `{ sessionId, projectDir }` (Task 543). Best-effort SIGTERM of the live PID (resolved from `sessions/<pid>.json` body match) then unlink the JSONL + `<sid>.meta.json` sidecar. Absent PID file is not an error. Containment: `projectDir` must live under `<CLAUDE_CONFIG_DIR>/projects/`. | `POST /` |
| `/session-rc-spawn` | POST `{ sessionId?, name? }` (Task 543). Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (Task 648, raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. The Sidebar navigates the opened tab to `claude.ai/code/session_<id>` on `outcome=bound`; on `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
| `/claude-sessions` | **Spawn surface only** (Task 500). `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (Task 626 removed the recorder loopback caller; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |

Task 500 — **admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot. The maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.

**Row title resolution.** Both the sidebar (`/sidebar-sessions`) and the manager's own row payload resolve a row's title in the same order: operator rename → Claude Code `ai-title` → first non-CLI user message → 8-char sessionId prefix. The operator-rename tier is the on-disk `<accountDir>/session-titles.json` (the manager's `UserTitleStore`), keyed by the CC sessionId — the sidebar reads that same file, so a write to the store lights up both surfaces with one write.

**Task 624 — maxy title for public sessions.** A `role=public` webchat spawn never produces a useful Claude Code `ai-title` (an anonymous one-line visitor turn), so every public row would otherwise read identically. The webchat route (`chat.ts`) composes a deterministic title — `Web · <senderId[:8]>[ · <personId>] · <UTC YYYY-MM-DD HH:mm>` (personId present only for gated visitors) — and threads it through the bridge (`dispatchOnce` → `managerSpawn`) to the manager `POST /spawn` body as `name`. `/spawn` validates it with `validateUserTitle` and, for `role=public` only, writes it into `UserTitleStore` so it occupies the operator-rename tier and wins over `ai-title`. Admin (`/rc-spawn`) and WhatsApp titling are unchanged. Observability: every public spawn logs `[spawn] role=public … title="…"` (or `title=missing`); the manager's row builder emits `[public-title] sessionId=… unexpected titleSource=<ai|null>` on the next list read for any public row that did not resolve from the user tier.

### Graph

| Mount | Purpose |
|---|---|
| `/graph-search` | Filtered node search backing the `/graph` page filter chips. |
| `/graph-subgraph` | Neighbourhood expansion around a focal node. |
| `/graph-delete` | Soft-trash a node (sets `_trashed:true`). |
| `/graph-restore` | Undo trash. |
| `/graph-labels-in-graph` | Distinct label list for the filter dropdown. |
| `/graph-default-view` | Account-scoped saved view (zoom, focal id, filters). |

### Artefacts and files

| Mount | Purpose |
|---|---|
| `/sidebar-artefacts` | Lists every editable artefact for the sidebar Artefacts view (KnowledgeDocuments + this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates). |
| `/sidebar-artefact-content` | Reads a single artefact's bytes for the artefact pane. |
| `/sidebar-artefact-save` | Persists an artefact edit. |
| `/attachment` | Per-attachment binary fetch (images, PDFs, etc.). |
| `/files` | File browser CRUD (list, download, upload, delete). Listings put directories first, then files newest-first by `mtime` (name tie-break) so a just-changed file leads the panel. |

**Artefact download resolution (Task 524).** Clicking a sidebar Artefacts row
streams the `KnowledgeDocument`'s real backing file, which can live in one of
three on-disk classes: the admin-UI upload store (`<DATA_ROOT>/uploads/<acc>/
<attachmentId>/`), the agent-authored output dir (`<DATA_ROOT>/accounts/<acc>/
output/<name>`), and the Claude-agent upload store
(`<CLAUDE_CONFIG_DIR>/uploads/<attachmentId>/`, which is **outside** DATA_ROOT).
`memory-ingest` persists the real path on the node as `KnowledgeDocument.sourcePath`
(skipped for web docs, whose temp file is unlinked at ingest), so new ingests
resolve deterministically; pre-existing rows fall back across the three classes.
The download route (`/files/download`) accepts `root=data` (default) or
`root=claude-uploads`; the config-dir root carries no accountId path segment, so
it is account-scoped by a graph-ownership check (the attachmentId must map to a
`KnowledgeDocument` carrying the caller's accountId) rather than by path
partition. Resolution emits `[admin/sidebar-artefacts] download-resolved via=…`;
a web/transient doc with no persisted file is `not-downloadable reason=no-persisted-file`.

**`/data` File panel — refresh and reload-survival.** The panel listing is a
snapshot from its last fetch, so a file an agent writes (or an upload/delete
elsewhere) leaves stale rows and timestamps until something re-fetches. A
**Refresh** button beside Upload re-fetches the current folder in place (fresh
`mtime`s, no browser reload). The current directory is mirrored into the URL
hash (`/data#path=<rel>`), so a browser reload restores that folder instead of
snapping back to the data root; the hash never reaches the server and `/data`
has no client router, so the channel is isolated. `path=` is cleared at the
root for a clean URL.

### Browser / device-browser

| Mount | Purpose |
|---|---|
| `/browser` | Programmatic Chromium launcher used by `personal-assistant` browser-automation flows. |
| `/browser-iframe` | Browser-iframe event ingest from the in-app preview surface. |
| `/device-browser` | Drives the device's own browser tab (VNC Chromium on Pi). |

### Diagnostics

| Mount | Purpose |
|---|---|
| `/logs` | Server log tail with `type=stream\|error\|sse` and `sessionId` filters; powers the in-chat **View logs** popover (see [`internals.md`](internals.md) "Conversations modal — View logs"). |
| `/events` | Generic SSE event ingest from the UI client. |
| `/log-ingest` | Loopback-only structured log ingest for MCP and PTY-spawn observability lines (see [`admin-session.md`](admin-session.md) "Memory MCP write-path outcome lines"). |
| `/claude-info` | Returns Claude CLI binary path, version, and OAuth status. |
| `/claude-capabilities` | Returns the resolved capability matrix the UI uses to gate features. |
| `/agents` | Lists every installed agent template; supports `DELETE /:slug` for user-created public agents and `POST /:slug/project` for project assignment. |
| `/cloudflare` | Tunnel setup surface — see [`cloudflare.md`](cloudflare.md) for the OAuth flow. |
| `/linkedin-ingest` | LinkedIn Basic Data Export ingest entry point (see [`linkedin-extension.md`](linkedin-extension.md)). |
| `/health-brand` | Brand-process liveness + 1 s Neo4j probe. Returns `{ok, processStartedAt, version, conversationDb: 'ok'\|'error', uptimeMs}`. The `processStartedAt` is captured at module load, so a stale value after an armed restart means the brand process never came back. |
| `/system-stats` | Host CPU / RAM / load probe. See next section. |

The companion `/api/admin/version` and `/api/admin/actions` routes are
served by `maxy-edge.service`, not this aggregator. The edge process
keeps the upgrade view alive while the brand service restarts.

## System-stats widget

Source:
[`server/routes/admin/system-stats.ts`](../../../ui/server/routes/admin/system-stats.ts)
(route) and `SystemStatsWidget` in
[`platform/ui/app/Sidebar.tsx`](../../../ui/app/Sidebar.tsx)
(consumer). CSS lives under `.system-stats*` in
[`platform/ui/app/globals.css`](../../../ui/app/globals.css).

**What the widget shows.** A compact block at the foot of the sidebar
with one row per metric: `CPU 73%` and `RAM 71%`, each followed by its
own 4 px saturation bar. Bar fill colour is a smooth green → amber →
red gradient computed as `hsl(140·(1−pct), 65%, 45%)` so the colour
reflects each metric's own load. Hidden when the sidebar is collapsed
to its 56 px icon rail.

**Where the numbers come from.** `GET /api/admin/system-stats` returns a
snapshot for the host. On Linux the route reads `/proc/stat` twice 100 ms
apart and computes `cpuPct = 1 - idleDelta / totalDelta`; `memUsedPct`
comes from `(MemTotal - MemAvailable) / MemTotal` in `/proc/meminfo`; the
three load averages come from `/proc/loadavg`. A single-flight module
cache means concurrent callers share one in-flight pair of `/proc/stat`
reads. On darwin and other non-Linux platforms the route falls back to
`os.totalmem() / os.freemem() / os.loadavg()` and returns `cpuPct: null`
— the widget renders a dash rather than fake a value.

**Refresh cadence.** The widget polls every 5 s
(`SYSTEM_STATS_POLL_MS`). Polling pauses while `document.hidden` is true
(tab in background) and resumes on `visibilitychange`. On unmount the
interval is torn down and the visibility listener removed.

**Thresholds.**

| Class | Trigger | Visual |
|---|---|---|
| `.system-stats--warn` | `cpuPct >= 0.9` **or** `memUsedPct >= 0.9` | Widget text turns `--danger` red. Bar fill colour is independent (set by the per-bar hue gradient), so the figure text is what carries the warn signal at this band. |
| `.system-stats--crit` | `cpuPct >= 0.98` **or** `memUsedPct >= 0.98` | Adds a 1.2 s pulsing background animation (`@keyframes system-stats-crit-pulse`) on top of the warn colour. |
| `.system-stats__fig--warn` | Per-figure: applied to the specific CPU or RAM span that breached `0.9`. | Same red colour, lets the operator see which of the two figures crossed first. |

The warn threshold matches the threshold the operator most commonly
cares about (a fully-loaded 4-core Pi at 16 GiB RAM crosses 0.9 long
before anything else on the host notices). The crit threshold pulses
because it is the band where a swap-thrash episode becomes likely.

**Failure handling.** Any read or parse error inside the route returns
HTTP 200 with `{degraded: true, reason, sampledAtMs}` so the widget
keeps showing its last-known value instead of flashing zeros. Failed
fetches log `[admin-ui] system-stats-fetch-failed status=<code>` (or
`reason=<message>`) to the browser console; successful polls are silent
client-side. Server-side every poll logs one
`[system-stats] poll cpuPct=<f3> memUsedPct=<f3> loadAvg1=<f2> swapUsedPct=<f3> platform=<linux|darwin|other> ms=<n>`
line; errors emit
`[system-stats] error file=<path|parse> reason=<message>`.

**Diagnostic grep.**

```bash
grep '\[system-stats\] poll' ~/.${brand}/logs/server.log | tail -20
grep '\[system-stats\] error' ~/.${brand}/logs/server.log | tail
```

A 0.000 reading that persists across many polls while `top` shows load
is the delta-cache regression signature (the single-flight promise was
not released).

## Sidebar surfaces

The sidebar is the entire left column of the admin UI. Its full layout,
responsive breakpoints, drag-resize behaviour, and the
new-session strip / nav rows / sessions list / footer ordering are
documented in
[`platform.md`](platform.md) "The Web Interface" — that paragraph is
authoritative. This section names the surfaces and what backs each.

| Surface | What it does | Backed by |
|---|---|---|
| `+ New session` button | Opens `NewSessionModal`, which POSTs `{channel, permissionMode, model, initialMessage}` to `/api/admin/claude-sessions`. | `claude-sessions.ts` |
| Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
| Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. | `/claude-sessions/events`, `/claude-sessions` |
| Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
| Artefacts list | Lists every KnowledgeDocument plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
| Footer | Operator avatar, name, role, and the actions popover. | `session.ts` |

## Artefact pane

The right-hand pane that opens when the operator selects an artefact,
clicks a project (Graph view), or opens Browser / Data / Graph from the
menu. It holds the surface side-by-side with the conversation so the
chat stays live.

- Editable artefacts (KnowledgeDocuments + the account's own IDENTITY /
  SOUL / KNOWLEDGE) auto-save on type via
  `POST /api/admin/sidebar-artefact-save`.
- Read-only artefacts (specialist agent templates) cannot be edited
  because they ship with Maxy and would be overwritten on the next
  install.
- PDF artefacts render inline; non-PDF binaries fall back to a Download
  button when the browser has no native viewer.
- Orphan rows (no readable file on disk, unsupported content type) show
  a one-line banner explaining the skip instead of opening to a blank
  pane.

Below 1080 px the pane hides and Browser / Data / Graph open as
full-window pages instead. The chat-and-pane divider is drag-resizable
on every admin page; double-click resets to half the available width
(viewport minus sidebar), clamped to the per-pane min-width floors. The
chosen width is remembered across reloads.

## Health vs version

Two endpoints, two surfaces, two restart-survival roles:

- `GET /api/admin/health-brand` (this aggregator) — brand-process
  liveness. `processStartedAt` resets on every brand-service restart;
  Neo4j probe is bounded to 1 s and reports
  `conversationDb: 'ok' | 'error'`. Use this to confirm the brand
  process came back after a Cloudflare-setup armed restart.
- `GET /api/admin/version` (maxy-edge) — installer / brand version
  string. Hosted on `maxy-edge.service` so the Software Update modal
  can read it while the brand service is mid-restart.

## Related references

- [`platform.md`](platform.md) — UI layout, session reconcile model,
  artefact pane behaviour in full detail, breakpoints.
- [`admin-session.md`](admin-session.md) — admin session token, PIN-
  rebind, SDK-resume, structured log lines.
- [`internals.md`](internals.md) — retrieval pipeline, end-turn auto-close
  auto-archive, graph-prune-denylist surface, conversation logs.
- [`cloudflare.md`](cloudflare.md) — tunnel setup OAuth flow that
  `/api/admin/cloudflare/setup` drives.
- [`deployment.md`](deployment.md) — Pi setup; the brand-service / edge-
  service split that the health-vs-version table above relies on.
