# 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 Real Agent installer; the Real Agent 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.

## UI conventions

**Icons are Lucide components only — no emoji.** Every glyph rendered in
`platform/ui` JSX (the `/chat` web chat, the WhatsApp/channel readers, the
admin shell, the `/data` grid) is a `lucide-react` component (e.g.
`<Paperclip>`, `<FileText>`), never an emoji/emoticon character in the rendered
string. The rule is enforced at build time by
[`scripts/check-no-emoji-in-jsx.mjs`](../../../ui/scripts/check-no-emoji-in-jsx.mjs),
which fails `prebuild` if any emoji codepoint appears in a `.ts`/`.tsx` file
under `app/` (tests excluded). Inline an icon at the surrounding text size and
colour (`size={14}` matches body text; Lucide inherits `currentColor`).

## 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. One JSONL on disk equals one row. The row's delete button 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 — 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. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name>  <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss, so a Sessions row and that conversation's reader-panel row read identically. A channel row name has two sources, in precedence order. First the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, which matches the sender phone against the account's admins — that names an admin WhatsApp session and every Telegram session. Second, for a `whatsapp` row only, the WhatsApp pushName from the account's on-disk message store (`storePushNamesBySender` over `readConversationSummaries`, keyed by `normalizeE164` of the counterparty phone so a `+44 7…` sender matches a `447…` store key). The store is consulted only when the resolver reports **no binding** for that sender. That is typically a public WhatsApp customer, the case this exists for — such a sender has no admin record and so can only be named by the store, and without it those rows degraded to the bare number while the reader panel named them — though an admin missing from `users.json` qualifies too. A resolver miss caused by Neo4j being unreachable deliberately does NOT qualify: that is an outage rather than an absent binding, so the row degrades to the number and still logs `reason=neo4j-down`, keeping the outage signal intact. Telegram has no message store and is never looked up. The store read is guarded: a corrupt or absent store degrades to no pushName and logs `op=store-unreadable`, never 500s the list. Both sources are the ones the reader itself uses, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The Sessions list reads the viewed account's own store only. An account-manager's thread persists under the house account rather than the sub-account it manages, so on a multi-account install viewing a sub-account that row is not named here even though the reader panel names it — the reader additionally consults the house store, filtered to manager phones bound to that sub. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> (operator=<n> store=<n>) unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line — emitted only when **both** sources miss, so a residual shortfall means a genuinely nameless sender rather than the expected public-customer floor, and the `operator=`/`store=` split says which source is carrying the names. | `GET /` |
| `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`. Sends **no** accountId. The manager resolves the session by scanning every slug under `projects/` for its JSONL (`slugForExistingJsonl`, covering `archive/`), exactly as the sidebar list enumerates rows, and gates on file existence alone — so any session the operator can see is deletable. This replaces the earlier accountId-derived slug and the `!row` term, which narrowed resolution below the list and left a session under a non-house slug with no watcher row (e.g. a legacy `-home-admin` session on a single-account install) listed yet un-deletable. Account scope is a view filter, not a delete gate — admin access is install-wide, so there is nothing to scope against. The sibling `/session-stop` (the standalone End control) resolves identically and also forwards no accountId. | `POST /` |
| `/session-rc-spawn` | POST `{ sessionId?, name? }`. 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** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. 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**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; 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 /` |

**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 reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. **For admin sessions, permission mode is fixed (Bypass) and not operator-chosen, while model and effort ARE operator-chosen at launch** via the composer's `+` new-session popover — a radio model list (Opus / Sonnet / Haiku) plus a 5-stop Faster→Smarter effort slider (Low / Medium / High / Extra / Max, where `max` applies via the `--effort` argv rather than `settings.json`); the popover description lives in `.docs/admin-webchat-native-channel.md`. Absent a pick (and on every resume), the daemon-pinned defaults govern: the `/chat` composer *footer* carries no live re-seat pickers — `rc-daemon.ts` pins settings.json `model`/`effortLevel`/`permissions.defaultMode` to `claude-opus-5[1m]` / `medium` / `bypassPermissions` regardless of `account.json` (read by the rc-spawn webchat path), and `pty-spawner.ts` forces the claude.ai/code PTY `--permission-mode` to `bypassPermissions` for `role=admin`. Public sessions are fixed to `claude-sonnet-5` (the `/public-spawn` chokepoint + `buildPublicWebchatSpawnRequest`), keeping `dontAsk`. **WhatsApp admin sessions override the daemon default:** the `/rc-spawn` handler pins `claude-sonnet-5` / `--effort max` / `bypassPermissions` for a `role=admin` WhatsApp spawn (constants `WHATSAPP_ADMIN_MODEL`/`WHATSAPP_ADMIN_EFFORT` in `http-server.ts`), pushing `--model`/`--effort` on both the fresh spawn and every resume so the params hold for the session's whole life. The override is scoped to sessions *born* under it: a fresh WhatsApp admin spawn stamps its classification sidecar `model=claude-sonnet-5`, and a resume re-asserts Sonnet/max only when that stamp is present — so a WhatsApp session created before this change (sidecar `model=null`) finishes its life on the Opus/medium default. Webchat and Telegram admin sessions are unaffected; public WhatsApp inbound never spawns. The composer footer instead renders a **read-only** params row (mode · model · effort labels) plus a context-window usage figure (`pct% · ~used/~window`), all sourced from the session pointer; the full path is in `.docs/admin-webchat-native-channel.md`. The composer cannot re-seat a session, so the "no transcript" 400 on a brand-new chat is unreachable; the `/api/admin/session-reseat` + `/api/webchat/settings` routes and the dashboard re-seat form stay as off-composer surfaces. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate 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.

**Session row delete, rename, and select mode.** The sidebar Sessions row supports three operator affordances. **Rename** (kebab → Rename) opens the inline edit input with its existing title pre-selected on focus, so the first keystroke replaces the whole title rather than appending to it — on both the sidebar (`conv-name-edit`) and the `/chat` Conversations flyout (`op-conv-rename-input`). **Delete** (kebab → Delete) arms a confirm that names the session (its label plus the 8-char id prefix) and renders as a popover anchored beside the row it will delete — reusing the same `AnchoredPopover` the kebab menu uses, not the shared bottom-right info dock — so the target is unambiguous; it still states the delete is permanent and, for a live row, that the session is stopped first. **Usage & cost** (kebab → Usage & cost) opens the per-day active-time / token / GBP-cost table for that session, and anchors beside its row through the same `AnchoredPopover`, with no scrim, so the rest of the dashboard stays legible and interactive while it is open. `AnchoredPopover` right-aligns to the anchor's rect and clamps horizontally against both viewport edges. Vertically it places below when the popover fits there, above when it fits there instead, and otherwise on whichever side has more room — and in every case it bounds the popover's height to the room on the side it chose. That bound is what keeps a long session's panel anchored: a 30-day usage table is taller than the viewport, and clamping it to fit would push it up over the very row it describes, which is not "anchored" to anything. Instead it sits flush against its row and scrolls internally. Because the bound is derived from the anchor rect and the viewport rather than from the popover's own height, the `ResizeObserver` pass it triggers recomputes the same values and settles in one extra pass. The horizontal right-edge clamp does not engage for a left-docked sidebar row (a popover wider than its anchor overflows left, which the left clamp handles); it is kept because the component is general-purpose and is covered by unit test only. Positioning is instrumented, because a mispositioned client-side panel throws nothing and the operator just sees a menu item that did nothing: every positioning pass (each open, each `ResizeObserver` callback, each window resize) emits `[anchored-popover] op=anchor id=<class>:<sessionId> present=<bool> rect=<x,y,w,h>` then `op=placed … maxH=<px> flipped=<bool> clampedLeft=<bool> clampedRight=<bool>`, and `op=offscreen` at `console.error` when the anchor is detached from layout or a placed rect misses the viewport. A pass that computes exactly what the previous pass did is suppressed, so mount and its trailing `ResizeObserver` callback log once rather than twice; the `op=offscreen` assertion is never suppressed. A healthy open is `present=true` with a non-zero rect followed by `op=placed` and no `op=offscreen`. `present=false` means the row is not in `rowRefs` — the row unmounted or its ref callback did not run — and the panel docks bottom-right rather than vanishing, because an invisible panel is indistinguishable from a broken menu item. An all-zero rect means the anchor is detached. The assertion is gated on the popover having non-zero measured size, so an unlaid-out popover (pre-paint, or jsdom) stays silent; a genuinely zero-size popover in production is therefore not reported. **Select mode** (a "Select" control in the Sessions header) turns each row into a checkbox and shows a "Delete N" bar; the row body toggles selection instead of opening, the per-row kebab is hidden, and confirming a count-based `BulkDeleteConfirmModal` (which names N and how many are live) loops the single-id `session-delete` over the selection (no route change — `/api/admin/session-delete` stays single-id). Bulk deletion is instrumented with a `[admin-ui] sidebar-bulk-delete op=start count=<N> live=<M>` / `op=done requested=<N> ok=<X> failed=<Y>` envelope around the per-session `sidebar-session-delete` lines, so a silently-dropped item shows as `ok+failed < requested`. Outside select mode the single-delete and row-open behaviours are unchanged.

**`/chat` empty-state starter chips.** While the admin webchat has no conversation JSONL yet (`GET /api/webchat/session` → `projectDir:null`), `app/chat/page.tsx` renders a "What's up next, {givenName}?" greeting (given name = first whitespace token of the admin session's `userName`; degrades to "What's up next?" without one) above three one-tap starter chips (replacing the earlier read-only lists): **Catch me up** (`Inbox`), **My day** (`CalendarClock`), **To-do** (`ListTodo`). `GreetingPanel` no longer fetches `GET /api/webchat/greeting` — the headline reads from the `userName` prop alone. A tap dispatches the chip's verbatim prompt through the existing `send()` with zero typing, via the same arm/fire path as the `?q=` handoff seed: `dispatchChip` logs `[admin-ui] op=chip-tap id=<catch-up|my-day|to-do> chars=<n>`, sets the composer text, and arms a one-shot effect that fires `send()` once the text matches. Chips gate to `variant` admin/operator; the public surface shows the headline alone. The first journaled turn replaces the panel with the transcript. The `/api/webchat/greeting` route (`server/routes/webchat-greeting.ts`) and `specialist-roster.ts` are now unconsumed by this surface — slimming them is a separate follow-up. The full webchat architecture lives in `.docs/admin-webchat-native-channel.md`.

**`/chat` composer transport button.** The button at the end of the input shows a **microphone** while the box is empty — tap it to record a voice note. Type a character, or attach a file, and the same button becomes the **send** arrow; while the agent is replying it becomes a **stop** square. So an empty composer offers voice, a composer with something to send offers send, and a running turn offers stop — one button, three states. The microphone-device chooser (which input to record from) stays in the row of icons below the box. This is the same on the admin `/chat`, the public chat, and the maxy-lite webchat.

**`/chat` Conversations flyout + zero-sessions splash.** The admin `/chat` `HeaderMenu` carries a **Conversations** item (rendered only when `onOpenConversations` is wired, which scopes it to `/chat`) that opens an in-chat session-management pane (`app/chat/SessionList.tsx`) hosted by `chat/page.tsx` — distinct from, and sharing endpoints with, the `Sidebar` Sessions list (which stays hidden on narrow viewports). It enumerates the admin's own webchat sessions via `GET /api/admin/sidebar-sessions` (carries the per-row `live` marker and `archived` flag, install-wide), **filtering out `channel:'whatsapp'`/`'telegram'` rows** (those are channel-bound, cannot be chatted in webchat, and live in the WhatsApp/Telegram reader panels; the same endpoint now also feeds the sidebar Sessions list, which does show them), lists the remainder newest-first with the live one marked and archived rows folded under a collapsible subsection, and offers per-row resume (`/chat?session=<id>`), rename, archive/unarchive, two-tap delete, an **End** control on the live row (`session-stop`), plus a New-session control (`session-rc-spawn`) and a copyable full id. The pane header carries the title and a top-right close (X) control that dismisses the flyout (no back-chevron/"Chat" label); the New-session control floats at the pane's bottom-left. When the canonical pointer is `known:false` (`canonical-empty`) and enumeration returns zero rows, the surface renders a splash (brand logo + New session) instead of a dead bootstrap thread; a freshly-spawned New session is `known:true`, so its greeting is preserved. Client breadcrumbs: `[chat-conversations] op=enumerate owned=<n>` and `op=action name=<open|rename|archive|delete|stop> sessionId=<id8>` (`op=action-failed … status=…` on a non-2xx). Detail lives in `.docs/admin-webchat-native-channel.md` ("Conversations flyout + zero-sessions splash").

**`/chat` header always shows the account name.** The admin/operator `HeaderMenu` centre always renders the active account's name alongside the brand logo, on every chat surface, regardless of the active conversation — `headerTitle = businessName || brand.productName`. There is no per-conversation header title: the active conversation's label stays in the sidebar and the Conversations flyout (each resolves its own per-session title independently), never in the header. The account name is `LocalBusiness.name`, resolved by `fetchAccountName(accountId)` in `neo4j-store.ts` with **no theme gate** — unlike `fetchBranding` (which returns null unless a colour/logo property is also set, and stays the source for actual theming). The house account is seeded with `LocalBusiness.name = <brand productName>` (`seed-neo4j.sh`, coalesced ON MATCH so a re-seed backfills a name-less node and never clobbers a name the business-profile recorder set later), so the sub-account picker's house row shows the product name (e.g. "SiteDesk"), not the raw accountId UUID. When a name is absent the resolver emits `[branding] account-name-fallback accountId=<id8>… reason=no-name`, and the picker falls back to the accountId; the seed + backfill is the name source. The lite variant header is unchanged.

**`/chat` sub-account switch starts a fresh conversation.** Switching sub-accounts from the header picker while on the chat page presents an empty greeting with no active session — no footer id, no highlighted conversation. The next message starts a **new** conversation for the switched-to account rather than reopening that account's previous one, and the previous conversation is left untouched and still resumable from the Conversations list. A normal visit to the chat page that did not switch accounts still reopens the account's most recent conversation as before. The full mechanism (the `clearSignal` first send and the `op=post-switch-pointer`/`op=send-target-resolve` diagnostics) lives in `.docs/admin-webchat-native-channel.md`.

**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.

**One delivered bubble per turn when the agent replied.** On the admin `/chat`, a turn that used the reply tool shows exactly one delivered bubble — the reply. A trailing sign-off or recap the agent writes after the reply is treated as narration and is not shown as a second bubble (it collapses like any mid-turn prose). Only a turn that ends **without** using the reply tool has its final text promoted to a delivered bubble, so a genuine no-reply answer is never lost. The tradeoff, accepted by the operator: a turn that acknowledges on the reply tool and then writes the real answer as trailing text will not show that trailing answer — the agent is steered to put the answer on the reply tool so this stays rare.

**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is authorised server-side (`authorizeReplyFileRef`) against the send's own account (the conversation owner) **or** the session's pinned account (`getAccountIdForSession` — the exact scope the DeviceFilePicker used to offer it), so a house/managing-admin session can attach a managed-client file the picker listed. Because a reply sends bytes OUT to an external WhatsApp recipient, any other account, a `public` partition, and any install-wide / non-account path are refused fail-closed; the cross-account admission's authority is the session's pinned account, never the caller-supplied body `accountId` (the own-account admission matches the body `accountId`, which the route already trusts via `isValidAccountId` and the existing-conversation guard). A scope refusal returns a distinct HTTP 403 reason (`file-ref-cross-account` / `file-ref-public` / `file-ref-foreign`) and copy, never "device file not found" (reserved for a genuine resolve/stat miss). Per-ref `[wa-manual-send] op=reply-file-ref … via=own|session|deny reason=<…> ok=<bool>` logs the decision before any send; `via=deny ok=true` is the exfiltration signature that must never appear. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.

**`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).

**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 native webchat gateway's public spawn (`handleInbound` → `buildPublicWebchatSpawnRequest` → `managerSpawn`) to the manager `POST /public-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.

**Public visitor surface.** The public-host root (`GET /`), the `/:slug` agent routes, and the admin-host `/public` / `/public-chat` previews all serve one shell, `public.html` → [`app/public-entry.tsx`](../../../ui/app/public-entry.tsx) → `PublicChat`. `PublicChat` reuses `useSession` purely as the magic-link gatekeeper — it drives `AccessGate`, resolves the agent slug from the path, and handles `?token=` verification; once the grant is satisfied it mounts `ChatSurface variant="public"`, whose transcript reads the visitor-scoped, **delivered-only** stream `GET /api/public-reader/stream` ([`server/routes/public-reader.ts`](../../../ui/server/routes/public-reader.ts)) — the visitor sees the agent's delivered prose, never the tool/tool-result/directive bytes the legacy `/api/chat` SSE render exposed. The human visitor's branding is resolved client-side by `useSession`. For the head only, the server injects per-agent link-preview meta — `<title>`, `og:title`/`og:description`/`og:image`, `theme-color`, and a per-agent favicon, resolved from the same branding cache `useSession` reads — into the served shell, so a link-preview crawler (which never runs the client bundle) gets a branded card per agent instead of one generic shell; an agent with no branding cache gets the clean brand-default shell with no empty meta tags. There is exactly one public client surface and it is 1:1 visitor↔agent: the earlier `?surface=next` A/B handle is retired, group messaging is retired (not a supported product surface), and a former `/g/<slug>` group URL now serves the same 1:1 shell — a stale bookmark gets the 1:1 chat, never a broken render.

### Routines

The Routines surface (`/routines`) is the admin view of the account's
automation `:Event` nodes — the device's scheduled agent turns and tool
dispatches, which the Calendar surface deliberately excludes (`calendar.ts`
unions only appointment events, `actionPlugin IS NULL`). It renders one card per
routine (title, human-readable schedule, next run, channel/destination or tool,
status); a card opens a detail modal modeled on the calendar `EventDetailModal`.
The modal edits the prose instruction (`agentPrompt`, agent-dispatch routines
only) and the schedule via a friendly builder (frequency daily/weekly/monthly,
day-of-week, day-of-month, time) that falls back to a raw-cron field for patterns
it cannot represent.

The route (`server/routes/admin/routines.ts`) is account-scoped like `calendar.ts`
(a foreign-account or non-automation `eventId` 404s):

| Route | Behaviour |
|-------|-----------|
| `GET /api/admin/routines` | Lists automation Events: `recurrence IS NOT NULL OR agentChannel IS NOT NULL OR actionPlugin IS NOT NULL`. Keyed by the `eventId` property. |
| `GET /api/admin/routines/:eventId` | One routine, same automation+account guard. |
| `PATCH /api/admin/routines/:eventId` | Edits `agentPrompt` and/or `recurrence`. Cron validation and the timezone-aware `nextRun` recompute copy the scheduling plugin's `schedule-update` tool. An invalid cron is rejected 400 with no write. Only creation and deletion stay agent-only (`schedule-event`/`schedule-cancel`). |

Observability rides `[admin:routines]`: `op=list accountId=<8> count=<n>`,
`op=get id=<8> found=<bool>`, and `op=patch id=<8> fields=<…> outcome=ok|rejected
reason=<invalid-cron|not-found> nextRun=<…> cron=<serialized>`. A `count=0` while
routines exist for the account is the account-scope-miss signature; the logged
`cron=` on `outcome=ok` confirms the persisted expression without opening the
graph.

### Skills

The Skills surface (`/skills`) is the admin view of the account's operator-
authored skills — the `SKILL.md` files the store-skill tool writes under
`<accountDir>/plugins/<plugin>/skills/<skill>/` (the durable per-account plugin
root, never the immutable `PLATFORM_ROOT/plugins` tree). It renders one card per
skill (name, owning plugin, frontmatter description, reference count); a card
opens a **read-only** detail modal showing the frontmatter, the SKILL.md body,
and the reference filenames. The page is read-only by design — skills are
authored and edited through the chat interface — with one exception: a **delete**
behind an explicit confirm step in the modal. A confirmed delete removes the
skill directory, and the whole plugin directory when that was its last skill.

The route (`server/routes/admin/skills.ts`) walks only the account tree, so
shipped platform skills never appear. Plugin and skill path params are kebab-case
validated (`/^[a-z0-9][a-z0-9-]*$/`) — the traversal guard; anything else 400s.

| Route | Behaviour |
|-------|-----------|
| `GET /api/admin/skills` | Enumerates `plugins/*/skills/*/SKILL.md`, returns `{plugin, skill, description, refCount}` per skill, sorted plugin then skill. A per-entry read error logs `op=list-skip` rather than silently shortening the list. |
| `GET /api/admin/skills/:plugin/:skill` | One skill's parsed frontmatter (`name`, `description`, `publicEmbed`), the SKILL.md body, and the reference filenames. Unknown skill 404s. |
| `DELETE /api/admin/skills/:plugin/:skill` | Removes the skill dir; removes the plugin dir when its `skills/` empties. Returns `{ok, skillDirGone, pluginDirRemoved}` measured after the rm. |

Observability rides `[admin:skills]`: `op=list accountId=<8> plugins=<n>
skills=<n>` (the surface's own heartbeat — `skills=0` against a populated
`plugins/*/skills/` is the broken-walk signature), `op=get plugin=<…> skill=<…>
found=<bool>`, and `op=delete plugin=<…> skill=<…> outcome=ok
skillDirGone=<bool> pluginDirRemoved=<bool>` (`skillDirGone=false` with
`outcome=ok` is a named failure) / `outcome=rejected reason=<invalid-name|
not-found|rm-error>`.

### Agents

The Agents surface (`/agents`) is the admin view of the account's agents. Each
card is tagged by **origin**, which says who put the agent there and therefore
what the operator may do with it. Origin is a different question to `kind`, which
says what shape the agent is: a shipped specialist and a user-created one are
both `kind: 'specialist'` and differ only by origin.

- **Public agents** — the per-agent directories under `<accountDir>/agents/<slug>/`,
  each carrying a `config.json` (`displayName`, `status`, `model`,
  `knowledgeKeywords`) plus the four owned docs `IDENTITY.md`, `SOUL.md`,
  `KNOWLEDGE.md`, `KNOWLEDGE-SUMMARY.md` (written by `public-agent-manager`). The
  public card shows a status dot; its **read-only** modal shows status, model,
  whichever owned docs are present, the knowledge keywords, and an open-agent link
  to the public URL (computed client-side: an `admin.` host maps to the `public.`
  host), plus a **delete** behind a confirm that runs the loud-fail `DELETE`
  (graph projection cleanup precedes file removal).
- **User-created specialists** (`origin: 'specialist'`) — the agent files under
  `<accountDir>/plugins/<plugin>/agents/<plugin>--<name>.md` (written by
  `agent-builder`). The specialist modal shows the model, tools, the owning
  plugin's skills, and the system-prompt body. Specialists are authored, edited,
  and deleted through chat (`agent-builder` / `specialist-management`), so the
  specialist modal offers no delete or open-agent link.
- **Shipped specialists** (`origin: 'shipped'`) — the union of
  `<accountDir>/specialists/agents/`, `<accountDir>/specialists/agents-disabled/`
  and `PLATFORM_ROOT/templates/specialists/agents/`, in that precedence order.
  `sidebar-artefacts.ts` reads the same union minus the quarantine directory, so
  a disabled agent still appears there through its bundled-template row. The
  route once walked only the account plugins tree, so the twelve
  highest-capability agents on the box were the ones the operator could not see.
  These are the only agents that can be disabled, and the route enforces that
  rather than relying on the UI to withhold the control.

The former "Public" flyout toggle in the account menu was removed; this page is
the sole agent surface.

**Risk class.** Every classified card carries a green/amber/red badge derived
from the agent's declared `tools:` line: green when every tool is `read`, amber
when the worst is `write_local`, red on any `exec` or `external`, and red when
any tool resolves to no class at all. Resolution has two sources, because
frontmatter names tools in Claude Code's namespace: a plugin tool is a direct
lookup in `ToolSurface.riskByTool`, which is keyed by the same
canonical `mcp__plugin_<p>_<p>__<tool>` string frontmatter writes, and the
eleven built-ins carry their own table in `server/lib/agent-risk.ts`. An empty
tool list is red: no `tools:` line means the full surface, not none of it.

A public agent carries `risk: null` and shows no badge. Its tool surface
resolves through `ADMIN_CORE_TOOLS` plus the plugin allowlists rather than a
`tools:` line, and classifying its empty list would paint every public agent red
on a premise nothing established.

The detail modal renders one chip per declared tool carrying **that tool's own**
class, so a `read` tool inside a red agent still reads as harmless; a tool with
no class is dashed and uncoloured rather than painted as if it were understood.
Badge colours come from the shared semantic status set: `-solid` with white text
for the badge, `-tint` with `-textOnTint` for a chip, never white.

**Disable.** `<accountDir>/specialists/agents/` is the live dispatchable set —
`spawn-context.ts` reads it to build the spawn manifest — so disabling **moves**
the agent file to `<accountDir>/specialists/agents-disabled/` and records the
basename in a 0600 `<accountDir>/agents-disabled.json`. A flag alone would leave
the agent running while the card claimed otherwise.

The file is moved, never deleted: a premium `--` agent and an operator-edited
override each exist only in the account dir, so deleting one and later restoring
from the bundled template would hand back a different agent than the operator
switched off. A disabled agent stays listed, marked `Disabled`, because an agent
that vanished from the surface that disabled it could never be switched back on.

`provision-account-dir.sh` re-reads the store after its core-specialist recopy
and removes anything named in it, so an upgrade does not silently re-deliver a
disabled agent. An unreadable store withholds nothing and says so: a corrupt
file is not evidence that an agent was disabled.

**Account scope.** The route (`server/routes/admin/agents.ts`) never infers an
account from device state. The two reads resolve from the caller's admin session
(`requireAdminSession` + the shared `accountDirForSession` in
`server/routes/admin/_account-scope.ts`), returning 401 when the session maps to
no valid account. The two writes carry no admin session (the
`public-agent-manager` skill calls them), so they take an explicit `accountId`
query parameter validated against the account list and refuse a missing or
unknown one with 400 — never defaulting to a resolved account. `:slug` params
reject `/`, `..`, `\` (the traversal guard); specialist detail is selected by an
explicit `?plugin=` parameter, not inferred from `--` in the slug.

| Route | Behaviour |
|-------|-----------|
| `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
| `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
| `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
| `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
| `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
| `POST /api/admin/agents/:slug/project?accountId=` | Re-projects the named account's on-disk agent into the graph. Same `accountId` contract as delete. |

Observability rides `[admin/agents]`, and **every line names the account it acted
on** (`accountId=<id8>`) — a well-formed answer about the wrong account is
otherwise indistinguishable from a correct one, which is exactly how an empty
Agents page survived undiagnosed on a multi-account install.

- `op=list accountId=<id8> agents=<n> specialists=<m> skipped=<x> specialistsSkipped=<y>`
  — the heartbeat. Compare `accountId` against the account the operator is
  viewing; `skipped>0`/`specialistsSkipped>0` is a malformed-source signal the
  page renders as a banner, and `specialists=0` with user plugins present is the
  broken-walk signature.
- `op=detail accountId=<id8> slug=<…> kind=<public|specialist>` — with
  `present=<comma-list|none>` on the public branch, where a role absent for an
  agent expected complete is the missing-doc signature. One
  `op=list-specialist-skip plugin=<p> file=<f>` per unreadable specialist.
- `op=<list|detail> auth-rejected reason=no-account-for-session` — the session
  maps to no valid account. No `op=list` line at all for a page load means the
  request never reached the handler, a distinct signature from a zero count.
- `op=<delete|project> rejected reason=<missing-accountId|unknown-accountId>` —
  a caller that did not name its account. A skill that was not updated produces
  this on its first call instead of writing to the wrong account.
- `op=delete accountId=<id8> slug=<…> outcome=<ok|failed>` with
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
  graph-cleanup throw).
- `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
  risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
  disabled=<bool>` — one line per classified agent per listing, so it fires on
  every page load. `riskiestTool` makes a wrong class diagnosable without
  re-deriving it. Its value is the FULL canonical tool name, while the card
  renders only the segment after the plugin prefix. One load
  showing `riskiest tool: memory-update` on the card and
  `riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
  confirmation that both derive from one value.
  **A non-empty `unresolved=` is the one to act on**: the agent
  is forced red, and the cause is that the built-in table or the registry
  mapping has drifted, not that the agent changed.
- `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
  not parse, so every row is forced red and the listing returns
  `riskSurfaceFailed: true`. Without the forced red, every plugin tool would
  land in `unresolved` and a registry outage would read as agent drift across
  every card at once; without the response field, a red row would be
  indistinguishable from a genuinely dangerous agent, because nothing resolved
  so `unresolved` is empty and the per-tool explanation cannot fire.
- `op=disabled-store status=parse-failed reason=<msg>` — the listing then treats
  every agent as enabled, which is what they actually are. A read surface shows
  the true dispatchable set rather than a comforting one.
- `op=<disable|enable> accountId=<id8> slug=<…> outcome=<ok|failed>`, with
  `moved=<bool>` on disable and `reason=nothing-quarantined` on a failed enable.

**Disable is a no-event failure, so it needs a standing check.** If the store
names an agent whose file is still in `specialists/agents/`, nothing throws, the
card reads disabled, and the agent keeps being dispatched. The five-minute
`account-dir-schema-reconcile` pass reports it as
`[fs-reconcile] op=agent-parity account=<id8> disabled=<n> stillOnDisk=<list>`,
fired only on disagreement. The per-cycle heartbeat is `agent-drift=0` on that
pass's summary line, which is what makes the absence of a per-account line
readable as "clean" rather than "never audited" — grep the summary line, not the
per-account one, to confirm the check is running.

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

**Node visual encoding.** Every node type that can appear on the canvas renders
a coloured disc with a white Lucide glyph, sized by incident-edge count, with a
matching legend swatch. Colour, shape, and glyph are three per-label registries
in `platform/lib/graph-style/src/index.ts`. Coverage is reconciled against the
true node-label universe, not the colour registry itself: the standing test
`__tests__/icons.test.ts` assembles the universe from the `Neo4j Label` column
of every `schema-*.md` node-type table, the `sourceLabel`/`targetLabel` set in
`typed-edge-schema.ts`, and the `CODE_EMITTED_NODE_LABELS` list, then fails the
build if any member lacks a colour or glyph. Labels that only ever stack on a
base node (`ADDITIONAL_BASE_LABELS`) inherit the base node's colour and are
excluded. A node type carries a coloured glyph even when its text caption is its
bare type: the glyph and the caption are independent channels (caption coverage
is the separate `caption-coverage.test.ts` gate). At runtime, a per-session
`op=icon-fallback` census (emitted from `app/graph/page.tsx` across the initial
render and every live-update poll) names any label that reached the canvas with
no glyph; an empty census is the healthy signal, a non-empty one means a
code-emitted label that no static source declares and should be added to
`CODE_EMITTED_NODE_LABELS`.

### Artefacts and files

| Mount | Purpose |
|---|---|
| `/sidebar-artefacts` | Lists the sidebar Artefacts rows: every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'` — all file types, excluding the separate `uploads/<id>/` subtree) plus 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. Upload is a raw-body stream (file bytes are the request body; `session_key`/`path`/`filename` in the query) written to disk without buffering — ceiling 2 GB (`MAX_DATA_UPLOAD_BYTES`), over-ceiling bodies abort mid-stream with `413`; the client shows a determinate progress bar. Distinct from the 50 MB in-memory chat-attachment cap. |

**Artefact download resolution.** 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`.

**Outbound file-delivery account scope (WhatsApp / Telegram).** When the agent
sends a file to a channel — WhatsApp `reply-document` or `SendUserFile`, Telegram
`SendUserFile` — the delivery core validates the file path against a single
account directory (`<DATA_ROOT>/accounts/<maxyAccountId>/`) and rejects anything
outside it. That validation scope is the sender's **effective session account**:
the account the session runs in, which for a WhatsApp account-manager routed to a
client sub-account is the gate-resolved sub-account (where the session's files
live), and for every other sender is the house account. It is **not** the
house/channel-routing account from `resolvePlatformAccountId()` (that helper
stays the inbound-persistence scope only). WhatsApp threads the effective account
from the inbound gate through the per-sender doc context (reply-document) and the
native file follower (`SendUserFile`); Telegram has no account-manager routing,
so the session's own spawn account is the scope. A rejected delivery logs
`[whatsapp:outbound] document REJECTED … reason=outside_account_directory
maxyAccountId=<scope>` (and the Telegram twin), so a wrong-account mismatch is
greppable from the log alone.

**`/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** icon inside the search bar (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.

**`/data` upload — button and drag-drop.** Besides the Upload button (one OS-picked
file, stored under a timestamped name), files and whole folders can be dragged
onto the file-browser body to upload into the open folder. A dropped folder's
subtree is recreated under the open folder and every contained file uploaded,
preserving source names and relative paths (a same-named file in an existing
folder is overwritten; empty source subfolders are skipped). The drop region
shows an active outline while a drag is over it and is inert wherever the open
folder is not writable (outside the operator's own `accounts/<id>/…` subtree),
rejecting the drop with the same "Open a folder inside your account to upload"
guidance as the button. Each drop reports an uploaded/skipped summary; a file
that fails the 50 MB limit, the MIME allowlist, or its HTTP write is skipped
individually without aborting the rest, and every uploaded file is indexed for
search exactly as the button path is. The server `/files/upload` endpoint takes
an optional `relpath` form field (the file's path relative to the drop root); it
recreates that path's directories under the open folder, scope-checked level by
level, and writes the file under its original basename. A flat upload (no
`relpath`) is unchanged.

**Data — admin Sidebar pane vs operator `/data` route.** The
file/search UI is the headless `DataFileBrowser` component. On the **admin**
dashboard it is a Sidebar surface: a "Data" nav row (beside Artefacts/Sessions)
sets `activeNav='data'` and, via `onSelectData` → `AdminShell`'s `dataOpen`
state, renders `DataFileBrowser` (through `DataPaneSurface`) in the shell's main
column — chromed by the admin `HeaderMenu` + `Sidebar`, no full-page nav, no
remount. Off `/` the row navigates to `/?data=1`, hydrated on mount
(`hydrateDataFromUrl`); the pane is mutually exclusive with the WhatsApp
Transcript. The admin burger no longer carries a Data item. The **operator**
surface has no Sidebar, so the operator keeps the burger → `/data` full-screen
route described next; only the admin path changed.

**`/data` operator surface.** The `/data` route mounts the operator
`HeaderMenu` (logo-left / name-centre / burger-right; flyout = Chat / Data /
Log out) above the toolbar — the burger's Chat item returns to `/`, replacing
the former standalone Home button. The search bar flexes to fill its toolbar
row; the four file actions (Refresh, Upload, New folder and the list↔grid view
toggle) sit beside it as one uniformly styled icon group, collapsing under a
single `⋯` overflow menu at `≤640px`. File rows are two stacked full-width lines
(name, then `size · modified`) with no file-type icon, no hover download icon,
and no per-row delete button; a folder row keeps its glyph and navigates on tap,
a file row downloads on tap. **Long-press** a file row to enter multi-select:
every file row shows a selection indicator, the search bar becomes a Cancel
control, and a fixed bottom bar offers **Share / Download / Delete** over the
selection. Delete removes the non-protected files and keeps protected ones
(`N protected file(s) kept`); the listing carries a per-entry `protected` flag
from `isProtectedFromDeletion` so the UI never re-derives the rule. Share uses
the Web Share API and is disabled where unavailable. Signals:
`[data-ui] op=mount header=operator home=absent`, `op=select-enter`,
`op=select-exit reason=<cancel|action>`, `op=share supported=<bool> count=<n>`.

**The dashboard has a surface ramp and a display type tier.** Every page sits on
a page plane, cards rise onto a raised plane, and inputs and scroll regions
recede onto an inset plane. Each brand chooses how the card separates: SiteDesk's
card is a shade lighter than its page and casts no shadow, Real Agent's card and
page are both white and a shadow does the work. The page header is an inverted
band carrying the page title at the display size. Colours, radii, shadows and
type weights all come from the brand's own tokens; nothing is hardcoded, and a
token that resolves to nothing fails the build rather than painting invisibly.

**Folders open beside a rail on a wide screen.** At 1280px and above the folder
view splits in two: a list of the account's folders on the left, the files on
the right, with the folder you are in highlighted. Below that width the rail is
hidden and the breadcrumb does the same job.

**Status colours are shared.** Success, warning, error and info each carry a
solid fill, a pale tint and a text colour for that tint. Every pairing meets the
WCAG AA contrast minimum, on every brand.

**`/data` opens on the account's own folders, as cards.** With no `#path=` in
the URL the browser lands on **home**: the account's business folders (the
entity folders of its trade, plus documents, projects, contacts, the work it
has produced for you, anything a client has sent in, and any websites it
has published) shown
one per card, each listing the first 20 things inside it and a `+N more` row
past that. Tapping a card opens that folder in the ordinary list or grid. There
are never loose files at this level. Everything the platform and its plugins
keep for themselves — caches, logs, published sites, plugin workspaces, hidden
folders — sits behind a single **system** card at the end, and opens as the
ordinary list too. The split comes from the account's own `SCHEMA.md`, so it
follows the graph ontology rather than a hardcoded list, and nothing moves on
disk. Upload and New folder are disabled on the cards view because it names no
single folder to write into; open a folder first. The flat account root is still
reachable from the account name in the breadcrumb, which is the way back if a
folder is ever grouped wrongly. Signals: `[data-schema] op=classify` on every
account-root listing (`home=`, `system=`, `unknown=`, `status=ok|parse-failed`),
`op=home-files` when a name that should be a folder is a file, and
`op=card-children … status=error` when one card's folder could not be read.

**`/data` list ↔ grid view + image thumbnails.** A toolbar toggle switches the
file browser between the two-line list and a **grid**. In grid view, image
files render an inline thumbnail; folders render a folder tile (still navigate
on tap); every other file renders a labelled tile (name + size) that downloads
on tap. Tapping a thumbnail opens a full-size overlay (reusing the chat
attachment overlay), not a download — download stays reachable via long-press →
select → Download. The chosen view persists across reload and `/data` ↔ `/`
navigation (`localStorage['maxy-data-view']`). A file counts as an image only
when its **extension** is one the inline-serve route can serve
(`png/jpg/jpeg/gif/webp/svg`, the server's `detectMimeType` image set) — the
sidecar `mimeType` is not consulted, so the grid never promises a thumbnail the
serve route would refuse. Thumbnails load from the same `/api/admin/files/download`
route with `disposition=inline` (`inlineImageUrl`), which for an `image/*` file
returns the bytes with **no** `Content-Disposition` and a cacheable header so the
browser renders them in an `<img>`; a non-image inline request is refused `400`,
and the account-scope guard is identical to the attachment path. Signals:
`[data] op=view-mode mode=<grid|list>`, `[data] op=thumb-serve path=… mime=… size=… status=200`,
`[data] op=thumb-refused path=… reason=not-image`, and client `[data] op=thumb-error path=…`
from a thumbnail that fails to load.

**`/data` search — locate-in-files link and clear button.** Every
search hit is a `:FileArtifact` backed by a physical file, so each result card
whose `relativePath` is a non-empty string carries a **locate link** (folder
icon + containing folder; root-level files show `data`). Clicking it clears the
active search (query, results, error, chip) and navigates the file browser to
that folder via the same `path` state + `#path=` hash contract above, so the
file appears among its siblings and a reload restores the place. The link is a
sibling of the clickable card, so it never triggers the row's download. Hits
without a usable `relativePath` render no link; one
`console.warn('[data-search] op=locate-link-missing nodeId=…')` per result set
surfaces the index gap, and each click logs
`[data-search] op=locate nodeId=… folder=…`. The search bar shows an **× clear
button** while it holds text — clicking empties the input, clears results
(restoring the file-browser body), and refocuses the input.

### Browser / device-browser

| Mount | Purpose |
|---|---|
| `/browser` | `POST /launch` — launches Chromium on the resolved transport (native display or VNC) on demand. Backs the standalone `/browser` operator page. Lifeline tag `[admin/browser/launch]`. |

The in-chat / artefact-embedded brand-VNC iframe components and the
`/browser-iframe` event-beacon route were retired and stay
deleted; the `/device-browser` (navigate-the-device-tab) route was also
removed and is not restored. The standalone `/browser` page is a
sanctioned operator surface peer to `/graph` and `/data`: it embeds the live
VNC viewer (`/vnc-viewer.html`, popout `/vnc-popout.html`) and calls
`/api/admin/browser/launch`. On darwin (no noVNC asset) it shows a "VNC surface
not available" state.

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

## Activity view

Source:
[`server/routes/admin/activity.ts`](../../../ui/server/routes/admin/activity.ts)
(house-gated proxy) and
[`platform/ui/app/activity/page.tsx`](../../../ui/app/activity/page.tsx)
(page). CSS lives under `.activity*` in
[`platform/ui/app/globals.css`](../../../ui/app/globals.css).

**What it shows.** `/activity` lists every live session across every account
in the install, one row per session: account, title, model, status, age, idle,
turn count, RSS, CPU %, cumulative tokens, and imputed GBP cost. Columns sort
on click. The sidebar session list is scoped to the caller's own account, so
this is the only surface that answers "where is the activity" install-wide.

**House account only.** The manager binds loopback and gates nothing, so the
scoping lives on the admin route. The house is resolved by the same three-arm
predicate the spawn path uses (`resolveAccountDir`): exactly one `role:"house"`
account, else a sole account declaring no role, else deny — two houses is
corruption and denies rather than picking one. Both operands are null-checked,
because `caller === house` alone is true for two nulls. A caller pinned
elsewhere receives 403 and a logged `op=gate decision=deny` carrying its
reason. The `HeaderMenu` item is admin-variant only for the same reason.

Be precise about what this is. Under the install-wide access model every
authenticated install admin can re-pin to any account via
`POST /api/admin/session/switch-account`, so the gate is a scope selector, not
an authority boundary between privilege levels.

**Where the numbers come from.** The manager assembles them at its own
`GET /activity`, joining the live PTY set, the watcher's per-row `accountId`,
the JSONL enumerator (turns, model, tokens, last message), the PTY census
(RSS and CPU), and a bounded metering fan-out. The admin route adds the one
fact the manager cannot see: the `data/accounts` directory count, taken
independently of `listValidAccounts()` so a rejected account shows as
`onDisk > valid` rather than silently shortening the list.

**Reading the cells.** A dash means unmeasured, never zero — an unmeasured CPU
is not an idle session. Cost is the price arm matching the row's own model,
because the metering core prices one token count under both Opus and Sonnet
assumptions without knowing which ran; a model in neither family shows a dash.
Every dashed cost carries its reason as a tooltip, so an unpriced model, a
failed parse and a session with no transcript are distinguishable rather than
three readings of the same glyph.
A session whose sidecar carries no account shows `untagged` rather than being
folded into the house. The census age is rendered because the snapshot is a 60s
sample and a dead collector emits nothing.

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

> **Redesigned chrome.** The admin shell was rebuilt so the sidebar
> owns all navigation; the full-width top header bar, the burger flyout, and the
> "Powered by Claude Code" footer strip are retired. After the redesign:
>
> - **Nav rows** are Dashboard, Chat, Tasks, Calendar, Routines (the automation
>   `:Event` nodes at `/routines`, `Zap` icon), Data, the brand container
>   row (Projects / Properties / Jobs, `Folder` icon, opening the graph filtered
>   to the brand's primary container), Graph (the unfiltered whole graph at
>   `/graph`, waypoints icon — active on plain `/graph`; the container row is
>   active only when a `label=` filter is present), Activity, and Browser. The
>   active row carries an accent left indicator. The older People / Agents / Artefacts entries in the
>   table below are pre-redesign.
> - **Dashboard home** renders on `/` (a greeting, stat cards, a Live-now column,
>   a Recent artefacts card, and a Channels column), consuming existing endpoints
>   only.
> - **Per-surface header.** Each surface carries its own header (`PageHeader`):
>   the sidebar collapse toggle, the page title, and a Claude status pill on the
>   right (Connected / Connect, driven by `/api/health`). The pill replaces the
>   retired burger Disconnect visibility and footer strip.
> - **Account kebab.** The version / Connect-Disconnect / Change PIN / Log out
>   items moved from the burger onto a kebab on the sidebar user footer
>   (`AccountMenu`, extracted from the old `HeaderMenu`; the operator variant
>   still mounts `AccountMenu` for its reduced set).
> - **Sub-account switcher on the brand head.** The head at the top of the sidebar
>   splits into two controls when the install has more than one sub-account: the
>   brand icon jumps straight to the house account, and the name plus chevron
>   opens the `SubAccountPicker` directly beneath the head (an outside click or a
>   second click on the name closes it). Clicking the icon while already on the
>   house account does nothing. A single-account install shows the same head as a
>   plain, non-interactive lockup, and the collapsed rail hides both controls. The
>   switcher used to sit in the account kebab; it no longer does. The switch
>   navigation itself (`useSubAccountSwitcher`, dropping the `?session=` param) is
>   unchanged.
> - **Mobile (< 720px).** The sidebar opens as a drawer from a 44x44 header menu
>   button, and a fixed bottom tab bar (Home, Chat, Tasks, Data, Calendar) sits
>   under the content; Graph, Activity, and Browser stay reachable through the
>   drawer. Between 721 and 1080px the sidebar defaults to the rail. The
>   drawer's SESSIONS head collapses its New-session pill to an icon-only `+`
>   (34px square); the label returns above 720px. The **operator** variant gets
>   its own fixed bottom tab bar — Chat, Conversations (rendered only when the
>   pane handler is wired), Data — while its burger keeps the AccountMenu tail.
>   In the webchat composer the icon control bar moves **above** the input box
>   with 44px touch squares (desktop keeps the below-input row), and the
>   composer placeholder never wraps (single line, ellipsized).

> **Admin variant only.** Every surface below belongs to the `admin` shell
> variant. The **operator** variant (`operator.<host>`) mounts no sidebar at
> all: its dashboard is the persistent admin webchat at `/` and the data
> browser at `/data`, plus a **Conversations** item reached from a dynamic
> burger item — header order Chat → Conversations → Data → Log out, in a
> single-column `.platform.platform-operator` grid. Below 720px the same three
> nav items also render as the operator bottom tab bar (see Mobile above);
> the Chat tab targets the operator root `/`, the canonical-session route. On the operator **chat**
> surface the Conversations item opens the same in-chat session
> switcher the admin `/chat` surface uses (`ChatSurface`'s `SessionList` flyout):
> the operator's own admin webchat conversations, newest-first, each resuming
> live via `/chat?session=<id>`, with rename / archive / delete / end / new — not
> a read-only reader. The item gates on the flyout handler being wired, exactly
> like the admin and lite items. The read-only inbound-channel **Conversations**
> reader (WhatsApp, Telegram, public webchat, via the admin-gated
> `/api/whatsapp-reader/conversations` route) remains on `/data`'s Conversations
> item and in the admin Sidebar; it is no longer reachable from the operator chat
> surface. The operator surface is documented in full in the internal doc
> `maxy-code/.docs/operator-dashboard.md`.
>
> **Operator `/chat`.** The reduced operator chrome renders on
> *every* operator chat surface, not just root `/`: the `/chat` server handler
> serves `operator.html` for an operator host (`chat.html` only for admin
> hosts), so `/chat` and `/chat?session=<id>` mount the same single-column
> operator shell. Unlike the operator root — which forces the canonical session
> — operator `/chat` honors `?session=`, opening a specific session inside the
> reduced shell. Admin-host `/chat` is unchanged (full developer shell).
> **Sub-account switch resets the session target.** Because `/chat` honors
> `?session=`, the header sub-account switcher (`useSubAccountSwitcher`) drops
> the `session` query param when it navigates after a switch, rather than
> reloading verbatim. A verbatim reload would keep the *previous* account's
> `?session=<id>`, which the reloaded surface re-pins — stranding the operator
> on the wrong account's session . Dropping the param lands on the
> account-scoped canonical surface, so `resolveCanonical` resolves the switched
> account's latest owned session, or the new-session splash when it has none.
> The pathname is preserved (a switch on `/chat` stays on `/chat`); the operator
> root `/` and bare `/chat` were always correct (they force-ignore / carry no
> `?session=`).
>
> **Width.** The operator header, the operator chat column, and the `/data`
> header+toolbar+body all render in one centred column bounded to
> `--chat-measure` (48rem). That token is a `:root` design token (beside
> `--measure`/`--gutter`); the operator-header clamp
> (`.admin-shell-root:has(> .platform-operator) > .admin-header`), the chat-column
> clamp (`.platform-operator > .webchat-page`), and the `/data` clamp
> (`.data-main`) all read it, so logo and burger align to the panel edges on both
> surfaces. In the full admin shell the one `--chat-measure` consumer is the
> main-column WhatsApp/Telegram viewer (`.platform > .wa-reader`), clamped and
> centred so its wallpaper reads as a bounded column, not an edge-to-edge
> surface; the rest of the admin shell stays edge-to-edge. The public webchat
> (`.public-surface`) inherits the same value.
>
> **Conversations item.** The dynamic Conversations burger item gates on the
> host wiring its open-handler (like the admin and lite items), not on a channel
> count. On the operator **chat** surface the handler is always wired, so the
> item is always present and opens the live session switcher (above). On `/data`
> the handler is wired only when ≥1 reviewable inbound conversation exists
> (fetched from `/whatsapp-reader/conversations` with the operator's session
> key), and selecting it renders the read-only reader in place (the file browser
> is swapped for it; Back returns) — so `/data` keeps its hide-when-empty
> behaviour. The flyout-open signal `[operator-ui] op=conversations-menu
> present=<bool>` fires for the operator variant on both surfaces; `present=false`
> while the operator expects the item is the handler-not-wired signature.

| 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. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so Real Agent reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `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. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
| Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/whatsapp-reader/conversations` |
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
| 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 `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) 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. | `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 Real Agent 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.

## Installable PWA

The admin (`index.html`), operator (`operator.html`), and chat (`chat.html`)
shells are installable. Each links its own webmanifest (`/manifest-admin`,
`/manifest-operator`, `/manifest-chat`) whose `icons` declare an explicit
`192x192` and `512x512` `any` entry plus a `512x512` `maskable` entry, and an
`apple-touch-icon`. These all point at an **opaque, padded** app icon baked on a
white tile — never the transparent brand mark, because iOS/macOS composite a
transparent app icon onto black. The opaque icons ship per brand
(`assets.appIcon` / `appIcon192` / `maskableIcon`); `manifest`
`background_color` only paints the splash screen, so the tile background is
baked into the PNG. The maskable variant carries a wider safe zone so Android's
circular crop never clips the mark.

The sign-in page is installable too. The remote-auth gate redirects every
unauthenticated request to it, so a fresh device installs from there; the page
links the admin manifest and the opaque `apple-touch-icon` so the install adopts
the brand name and icon instead of a monogram generated from the title. The
sign-in page registers no service worker — only the authenticated shells do.

A boot census logs `[pwa-census] surfaces=3 manifest-linked=3 icons-present=3`
when every shell links its manifest and the app-icon files are present; a missing
or mis-named icon asset shows `icons-present<3`, and a dropped head injection
shows `manifest-linked<3`. The graph, data, and browser shells are intentionally
non-installable.

## WhatsApp socket resolution

Every route that needs a live WhatsApp socket resolves it through
`resolveSocket` in
[`app/lib/whatsapp/manager.ts`](../../../ui/app/lib/whatsapp/manager.ts),
never through the raw `getSocket` primitive it is built on. `getSocket` is
an exact-key lookup, and a connection is keyed by its credential dirname
(often a legacy `default`) while callers frequently hold the platform
account UUID. A raw lookup therefore returns `null` for a socket that is
alive under the other key.

`resolveSocket` accepts either key and splits that `null` into the two
failures that demand **opposite** operator advice:

| Reason | What it means | Correct advice |
|---|---|---|
| `key-miss` | No connection is registered under this id. The socket is likely alive under a different key. | Fix the account keying. **Never advise re-pairing** — that would re-pair a healthy socket. |
| `disconnected` | The connection exists and its transport is genuinely down. | Transport-down; reconnecting is appropriate. |

The reason label, the `presentKeys` rendering, and both operator-facing
messages have one definition, in
[`app/lib/whatsapp/socket-resolution-error.ts`](../../../ui/app/lib/whatsapp/socket-resolution-error.ts).
Callers keep their own log shape, HTTP status, and throw-vs-return
handling, but never hand-write the message strings — a copied key-miss
message is how the "do not advise re-pairing" instruction drifts out of
one branch unnoticed.

Two callers predate the shared formatter and still carry their own
equivalent wording: the reader reply path and the `/send-admin` route.
Consolidating them is tracked separately; do not copy their strings into
new callers.

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