# How {{productName}} Works

## The Short Version

{{productName}} runs on a Raspberry Pi in your home. It uses Claude (Anthropic's AI) as its brain and extends it with plugins — modular capabilities like contacts, Telegram, and memory. Everything stays local: your data, your conversations, your memory graph.

## The Raspberry Pi

{{productName}} is a server that lives on your local network. It's always on, always available, and accessible:

- **Locally:** `maxy.local:19200` (or the IP address of your Pi)
- **Remotely:** via your personal domain, routed through a Cloudflare tunnel

The Pi runs the web interface, the AI agent, and all the plugin servers. When you send a message, the Pi processes it — not a cloud service.

## The Two Agents

{{productName}} runs two agents simultaneously:

**Admin agent (you)** — full access to all tools and plugins. This is the agent you interact with at your local or remote URL. It can read and write contacts, send Telegram messages, manage your account, and perform any task you have plugins for. Protected by your PIN. Your admin agent runs through your own Claude Code OAuth session — it never bills the Anthropic API. Authentication and SDK details are documented in the developer doc `.docs/platform.md` admin-agent section.

**Public agent (visitors)** — read-only access. Handles enquiries from people who reach your public URL. It can answer questions about your business and collect prospect contact details, but it cannot access your private data or take actions.

## Plugins

Everything {{productName}} can do is provided by a plugin. Each plugin is a self-contained package:

- Behaviour instructions (how the agent should act)
- Tools (specific actions the agent can take, exposed via MCP servers)
- Reference documents (detailed knowledge loaded on demand)

**How tools and roles reach the session.** Each `claude` PTY spawn registers every plugin's MCP server and every bundled subagent directory before the operator's first turn — a per-spawn `mcp-config.json` written by the session manager and passed as `--mcp-config` on the PTY argv, plus one `--add-dir` per agents directory. Admin sessions see every plugin and every role; public sessions see only plugins with at least one public-allowlisted tool. The manager refuses to start when a plugin's `PLUGIN.md` declares tools without a matching `mcp:` block (forensic signal: `boot-failed reason=mcp-allowlist-without-server …`). See `internals.md` "Spawn-time MCP and subagent registration" for the full mechanism and `internals.md` "Tool Eagerness" for the separate ToolSearch-vs-eager registration concern.

**Where premium bundle subs live.** Bundle subs (`loop`, `property-data`, `brochures`, etc. inside `real-agent`) live exclusively at `premium-plugins/<bundle>/plugins/<sub>/` and are registered via the resolver's bundle-descent walk. Standalone premiums (no `BUNDLE.md`, e.g. `writer-craft`, `teaching`, `venture-studio`) live exclusively at `premium-plugins/<name>/` and are registered via the resolver's dual-root scan (`platform/plugins/` and `premium-plugins/` are both `pluginsRoots`). Neither shape is flat-copied into `platform/plugins/<name>/`. A divergent flat copy of a bundle sub is treated as an operator override: the resolver refuses to boot with `boot-failed reason=mcp-plugin-duplicate <plugin> declared by more than one plugins root: <pathA> (sha=…) vs <pathB> (sha=…)` so the operator can `sha256sum` both paths and remove the stale one. Byte-identical bundle-sub flat copies left over from installer versions that flat-copied bundle subs are reaped on the first post-upgrade boot (`[premium-auto-deliver] reaped sub=<name> reason=duplicate-of-premium-tree`). Standalone flat copies (leaked by the pre-fix `autoDeliverPremiumPlugins` standalone branch) are reaped unconditionally — there is no documented override path for standalones at `platform/plugins/<name>/` — and the reaper logs `[premium-auto-deliver] reaped standalone=<name> matches-source=<true|false>` so divergent reaps leave a forensic trail.

Plugins are installed and managed through conversation. You can add marketplace plugins (like Stripe) or use {{productName}}'s built-in ones (contacts, memory, Telegram).

## Roles

{{productName}} ships twelve roles it can dispatch for specific tasks — like members of your team. You don't need to configure or manage them — {{productName}} decides when to use each role and handles everything automatically. You may see activity like "Dispatching personal-assistant..." in the chat timeline when this happens.

The catalogue below is what the platform ships. It is not evidence of what is installed on the current account. For the live install set on this account, ask {{productName}} to call `capabilities-here`.

| Role | What it does |
|------|-------------|
| Archive Ingest Operator | Ingests bulk external archives — Obsidian, ICS, X, Notion — and surfaces schema-mapping ambiguity rather than catching all unmapped relations as :MENTIONS. |
| Citation Auditor | Audits :TimelineEvent rows for missing citations and writes either citations directly or a CitationProposal stub. |
| Coding Assistant | Runs shell commands, drives git repositories, and reads or edits code on your behalf — the specialist you reach for when the work belongs in a developer terminal. |
| Compiled Truth Rewriter | Recomputes a node's compiledTruth (and public twin where applicable) from its 90-day timeline plus optional operator hints. |
| Content Producer | Produces visual output from your graph: generates images, renders pages to PDF, and hosts static websites you upload as a zip. |
| Database Operator | Executes graph writes on admin's behalf when delegated via the Task tool — admin names each write, the specialist runs it. |
| Librarian | Owns foreground ingest of documents, conversation transcripts, and external archives into the memory graph. |
| Personal Assistant | Handles the operational tasks you'd give a personal assistant: scheduling meetings, managing your platform settings, connecting messaging channels, and completing browser-based tasks on your behalf. |
| Project Manager | Manages your tasks, projects, sessions, and workflows: linking work to people and goals, and keeping everything organised. |
| Public Session Reviewer | Reads a gated public-agent transcript and dispatches database-operator for each per-visitor memory write. |
| Research Assistant | Researches topics online, manages your knowledge graph, and produces supporting visuals. |
| Typed Edge Classifier | Reads recently-written prose nodes and writes typed edges from a closed allowlist. |

Roles are installed during setup and listed when {{productName}} introduces itself. Some premium bundles add their own specialists (e.g. the `real-agent` bundle adds a listing curator, negotiator, valuer, compliance officer, and buyer-enquiry public agent). Roles installed mid-session become active from the next session.

## Memory

{{productName}} maintains a graph database (Neo4j) of everything you've told it. People, conversations, preferences, and context are stored as connected nodes. When you ask {{productName}} something, it searches this graph to retrieve relevant context before responding.

**The recording loop.** {{productName}} dispatches `database-operator` inline at its own discretion when a write must complete before the assistant response ends. The full graph-completeness sweep runs on demand: when you invoke the `/insight` admin skill, it runs a four-pass instruction and {{productName}} walks the session for any node, edge, or commitment that was discussed but not written in-flight, dispatches one `database-operator` Task per candidate write, then carries on in the same session.

The memory graph is stored on your Pi. It never leaves your network.

The graph view (at `/graph`) lets you explore the memory directly. Pick a category from the filter, then type to search inside it — typing makes the canvas narrower, not wider. Drag the slider to control how many matches you see (1 to 2000). If the search shows a yellow banner saying "Vector ranking unavailable," it means the local AI ranking model is offline; results are still returned using keyword match, but ordering is less semantic until the ranker recovers.

## The Web Interface

The web app runs on your Pi on port 19200. A small always-on front door (`maxy-edge`) owns that port. The edge also hosts the `/api/admin/version` route so the HeaderMenu version display keeps reading even during a mid-restart of the brand service. Login cookies are HMAC-signed with a shared key on disk, so both processes recognise the same session without any coordination and you do not have to log in again after an update. Every request is also classified as LAN or external based on the network shape it arrived on — LAN browsers reach admin directly; the remote password screen only appears on the tunnel-exposed admin domain. It provides:

- **Admin chat** (at `/`) — your primary interface, PIN-protected
- **Public chat** (at `/{agent-name}`) — visitor-facing agents, each with their own URL. On public hostnames, the root path serves the default agent.
- **Telegram bot landing** (at `/bot`)

There is no dashboard, no settings panel, no menus. Everything is done through conversation.

The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.

The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every admin page (Sessions root, Graph, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all three pages. The drag handle is mounted by each AdminShell consumer rather than by AdminShell itself, so any new admin route must include `<SidebarSplitter />` as a direct child of its `<AdminShell>` to pick up the shared width. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).

Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `Maxy`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.

**Session lifecycle and reconcile model.** The sidebar Sessions list is driven by a single Server-Sent Events feed at `/api/admin/claude-sessions/events`. The session manager watches the two directories Claude Code writes (`${CLAUDE_CONFIG_DIR}/sessions/<pid>.json` for live state, `${CLAUDE_CONFIG_DIR}/projects/<slug>/<sid>.jsonl` for transcripts) and emits `row-created`, `row-updated`, `row-archived`, or `row-removed` deltas to every connected browser tab. Three real delete shapes map to deltas — there is no fourth: PID file gone with JSONL surviving demotes the row to `row-archived`; PID file gone with no JSONL ever written (a hidden spawn that exits before writing a JSONL) emits `row-removed` against the unindexed sessionId; a JSONL deletion against an already-unindexed row also emits `row-removed`. This branch reconciles transient hidden spawns — without it, ghost rows persist after a hidden spawn exits. On connect the manager replays the current row index so a freshly-opened tab catches up without polling, then streams deltas as files change on disk. Two open tabs see the same list within ~300ms of any spawn, status flip, or exit; no refresh button required for state to be current. The legacy `/list` fetch and `useAdminSessions` hook stay mounted to serve the ConversationsModal and the post-action reconcile path in `session-actions`, but the sidebar's visible rows come from the row store, not from `/list`. Each EventSource open emits `[admin-events] client-connected ip=<…> seeded-rows=<n>` server-side and `[admin-ui] session-row-store connected events-received=<n>` in the browser console; transport drops log `[admin-ui] session-row-store reconnect trigger=<auto|manual> attempt=<n> delay-ms=<n>` until the EventSource reattaches. The small dot at the right edge of the Active/Archived/All segmented control is the live-updates indicator: sage when the SSE feed is connected, grey when the feed has dropped. The grey state is an actionable button — clicking it cancels any pending backoff and re-opens the feed immediately, with the click logged as `trigger=manual` so manual retries are distinguishable from automatic ones in the console. The refresh icon at the top of the Sessions list is the operator-recoverable reconcile path against any SSE gap: it fetches `/api/admin/claude-sessions` and passes the authoritative id set to the row store, which evicts any indexed row that the server no longer reports. SSE replay only re-asserts currently-indexed rows and never emits `row-removed` for a row that vanished while disconnected, so without this manual surface a stale row can persist until the operator reloads the tab. Each click logs `[admin-ui] session-row-store reconcile evicted=<n> kept=<n>` when at least one row is evicted, and is silent otherwise.

The row feed sits behind `requireAdminSession` like every other admin route, so the URL must carry `?session_key=<cacheKey>` — `EventSource` cannot send custom headers, so the query string is the only viable transport. Every admin URL (fetch and EventSource alike) routes through the shared `appendAdminSessionKey(url, cacheKey)` helper exported from `app/lib/useAdminFetch.ts`, which is the single source of truth for the convention; no caller constructs the query string by hand. On a 4xx rejection the browser-side store probes the same URL once per reconnect (suppressed after a successful `open`, capped at one fetch per attempt) and logs `[admin-ui] session-row-store sse-error status=<n> code=<code> attempt=<n>`. The `code` field uses the closed `AdminSessionRejectCode` taxonomy (`session-missing | session-not-registered | session-expired-age | grant-expired`, plus a default `unknown` bucket) that mirrors the server-side rejection emitted by `requireAdminSession`, so a single grep correlates client and server timelines on the same code.

The trade-off is a longer-lived connection per tab: the manager's per-process subscriber count rises with open tabs, and the SSE channel must survive proxy idle timeouts. The manager emits a 25-second keep-alive comment line on every connection (ignored by EventSource consumers, refreshes the proxy clock) and the browser-side store force-closes-and-reconnects on transport errors with exponential backoff capped at 30s.

The row payload carries `url: string | null` (Tasks 189 / 260) — the `claude.ai/code/session_<suffix>` URL captured from the `/remote-control` banner. **Task 260 — disk is the only source of truth.** Spawn metadata that previously lived in an in-memory `SessionStore` (senderId, role, channel, url, startedAt, permissionMode, model, hidden, specialist) now rides a sidecar file alongside the JSONL: `<projectsDir>/<sessionId>.meta.json`. The watcher reads the sidecar at row-build time and stamps the nine fields onto the `SessionRow`; the serialiser reads `row.url` directly with no in-memory side channel. The value is `null` whenever the spawn is headless (`HEADLESS_ROLES`, Task 171 — `--remote-control` not passed), or before url-capture has fired on a channel-facing spawn (~2 s after spawn), or on rows whose JSONL+sidecar pair was archived before the banner landed. When url-capture eventually fires, `pty-spawner` writes the URL to the sidecar via `updateSidecar`, calls `watcher.refreshSidecar(sessionId)` to refresh the row index, and the manager pushes a `row-updated` SSE frame carrying the fresh URL — the client's Open-in-new-tab arrow appears in step. The Sidebar gates the arrow on `row.live && row.url !== null` and opens `row.url` directly with no `/meta` round-trip; each click logs `[admin-ui] sidebar-open-in-new-tab outcome=<ok|blocked> sessionId=<8-char>` (`blocked` fires when a popup blocker swallows `window.open`).

**Manager state shape (Task 260).** The manager keeps exactly two pieces of in-process state — the live `PtyHandle` map (in `pty-spawner.ts`, keyed on sessionId, holding the file descriptor and runtime flags that cannot go on disk) and the watcher's row index (rebuilt from disk on each event). Everything else lives on disk: the JSONL transcript at `<projectsDir>/<sessionId>.jsonl` (live) or `<projectsDir>/archive/<sessionId>.jsonl` (archived), the sidecar at the matching path with `.meta.json`, and the PID file at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. A manager restart re-reads the sidecars at boot so every row that had one before the restart re-enters the in-memory index with full senderId/role/channel populated. Pre-Task-260 archived JSONLs (created before the sidecar writer existed) index normally but with seven null sidecar fields. The watcher enumerates BOTH the top-level projects dir AND its `archive/` subdir, watches both with `fs.watch`, and coalesces a top↔archive rename into one `row-updated` event (no `row-removed` followed by `row-created` — the rename is one logical state change keyed on sessionId). The sidebar surface that consumes this index is `/api/admin/sidebar-sessions` (Task 538), not the legacy session-manager `/list` route, which has been removed.

**Spawn lifecycle: PID-file driven.** Clicking "+ New session" opens the `NewSessionModal` (Task 223). Modal submit POSTs to the wrapper with the operator's typed text as `initialMessage`, plus per-session `permissionMode` and `model` overrides; only then does the PTY spawn. The manager waits for Claude Code's PID file at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. The JSONL's first `role=user` line equals the operator's typed text byte-for-byte; Claude Code's `tail.aiTitle` is computed from that real content and remains the canonical sidebar row label. The wrapper at `platform/ui/server/routes/admin/claude-sessions.ts` is still the single canonical entry point for any programmatic admin spawn-with-prompt — see `admin-session.md` "Spawn-with-initialMessage wrapper" and `internals.md` "Programmatic spawn entry point". Resume flows are unaffected (the prior transcript is the stimulus).

The sidebar row's displayed name is `tail.aiTitle` verbatim, parsed by `jsonl-enumerator.ts` from the JSONL Claude Code writes. Until Claude Code has written its title, the row label is null and the cell renders empty — no UI-stamped sidecar layer, no 8-char id fallback. When Claude Code later updates its title mid-session, the next `/list` or `/events` tick surfaces the new label. Task 146.

Each session row also carries a small muted timestamp crumb under the name showing when the session was last active: "just now", "5m", "3h", "yesterday", a weekday name for 2-6 days back, "20 May" for older dates this year, or "20 May 2025" for prior years. Live rows tick forward on their own without a refresh — every row advances together on a single shared 30-second cadence so two rows with identical names (a fresh session whose `aiTitle` has not landed yet, plus a resumed session whose title also has not landed) are distinguishable at a glance. A row that renders "—" instead of a time is a loud-fail signal: the session manager lost the row's `updatedAt` (the JSONL `mtimeMs` for archived rows, the PID-file `updatedAt`/`startedAt` for live rows). Investigate the server log rather than treating "—" as a normal value. The pure formatter lives at `app/lib/relative-time.ts` and is pinned by `app/lib/__tests__/relative-time.test.ts` (every breakpoint, every '—' input, DST cross 2026-03-29); the shared tick is `app/lib/use-now-tick.ts`; the row-render wiring is pinned by `app/__tests__/Sidebar-timestamp.test.tsx`. Task 187.

**Stop vs. delete.** `POST /<id>/stop` sends SIGTERM, leaves the JSONL on disk for audit, and is idempotent against an already-dead row. `DELETE /<id>` removes the JSONL + per-session subdir and returns 409 if the PTY is still alive (stop first). Any unknown id returns 404; nothing returns a silent 204 against an id the manager does not know.

**View JSONL (Task 198).** Alongside the Download button, the pane carries a **View JSONL** button that opens a full-pane modal streaming the transcript in-app from `GET /<id>/log?follow=1`. The modal is the canonical surface for reading transcripts inside the admin UI — Download remains the export route for offline / external tooling. The viewer renders one row per line, collapsed by default to a role badge plus a 200-char preview; click a row to expand into the pretty-printed JSON, click again to collapse, or click the copy icon to copy the raw line bytes (round-trip integrity preserved — no re-stringify). A search input filters visible rows by case-insensitive substring match against the line's JSON; the stream keeps landing in the backing list regardless of filter state. For live sessions (`status: 'alive'`) the modal tails new lines as they're written; for ended sessions it renders the initial-read flush and then idles. The status pill in the footer reflects the live session status (alive → "streaming", ended → "complete"), not the underlying stream state — keeps the operator's mental model aligned with the pane's other indicators even though the manager's `/log?follow=1` keeps the underlying watcher open until aborted. Malformed lines (`JSON.parse` failure) render inline as a `parse-error` row with the raw text and the failure reason; the stream continues. The backing list caps at 50,000 entries (ring buffer with eldest-drop); past the cap, the header reads "N older dropped" — the cap protects browser memory on multi-day database-operator sessions where the JSONL can grow to tens of thousands of lines. Closing the modal (X button, overlay click, or Escape) aborts the fetch (`AbortController.abort()`) which propagates to the manager's `out.onAbort` and releases the `watchFile` listener. Observability: the manager emits `[claude-session-manager] log-follow-open sessionId=<sid> initialBytes=<n> pid=<n>` when the stream opens (after the initial-read flush) and `log-follow-close sessionId=<sid> reason=aborted linesStreamed=<n> ms=<n>` when it closes — `linesStreamed` counts `\n` bytes written across both initial-read and tail, matching `wc -l`. The browser console mirrors with `[admin-ui] jsonl-viewer-open sessionId=<8> alive=<bool>` on mount and `[admin-ui] jsonl-viewer-close sessionId=<8> reason=unmount linesRendered=<n> ms=<n>` on unmount, plus `[admin-ui] jsonl-viewer parse-error sessionId=<8> lineNumber=<n>` once per malformed line (capped at 100/session to avoid console flood). Auth is unchanged — the existing `requireAdminSession` middleware covers `/log?follow=1` exactly as it already does for `/log?download=1`.

**Download JSONL (Task 197).** `GET /<id>/log?download=1` is a one-shot byte-stream of the session's JSONL transcript with attachment-disposition headers, designed for the pane's **Download JSONL** button. Headers: `Content-Type: application/x-ndjson`, `Content-Disposition: attachment; filename="<sessionId>.jsonl"` (the basename is sanitised so any non-`[A-Za-z0-9._-]` character is replaced with underscore), `Cache-Control: no-store`. Four status branches: **200** with the byte-identical file body; **404** `{error: 'session-not-found'}` when the store has no row for the id; **202** `{pending: true, jsonlPath: null}` when the row exists but claude has not flushed the first turn yet; **404** `{error: 'jsonl-missing-on-disk'}` when the row carries a `jsonlPath` but the file has been removed under the manager (post-Purge race). The download branch is declared **before** the follow check, so `?download=1` always wins over `?follow=1` if both are set. The proxy at `app.get('/:sessionId/log')` rebuilds the upstream query from a fixed `follow|download` allowlist; inbound query keys outside that allowlist are dropped. Observability: `[claude-session-manager] log-download sessionId=<sid> bytes=<n> ms=<n>` lands per successful stream completion; the browser console emits `[admin-ui] pane-download-jsonl sessionId=<8> outcome=initiated` on click. `outcome=initiated` rather than `outcome=ok` is intentional — the handler resolves before the browser writes the bytes, so the log line names "the request was kicked off", not "the file landed". If the file does not appear in the operator's downloads folder, check the manager line for the bytes count and the browser's downloads UI for the suppression record. Auth is unchanged from the rest of the `/api/admin/claude-sessions` surface (cookie session via `requireAdminSession`); there is no new key surface.

**Two spawn surfaces, one primitive (Task 573).** The manager runs two on-device spawn surfaces, both backed by the same primitive: **node-pty wrapped in `systemd-run --user --scope`** (via `index.ts::spawnPtyAdapter`).

- **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`. One supervised daemon per account; owns the long-lived composer session that backs claude.ai/code Remote Control. Master fd held for the daemon's lifetime, released on natural exit / restart. **Headless consent pre-seed (Task 578).** Before the first spawn, `ensureRemoteControlConsent` writes `{"remoteControlAtStartup": true}` into `$CLAUDE_CONFIG_DIR/.claude.json` (read-merge-write, atomic tmp+rename, idempotent). Without this, headless `claude rc` hangs at `Enable Remote Control? (y/n)` — nothing answers, the supervisor restarts the child, eventually marks the daemon permanently-failed. The key is the same one `claude` itself writes when the user answers `y` at the prompt; siblings (`teammateMode`, `hasUsedRemoteControl`, claude's auth blocks) are preserved.
- **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`. One PTY per click; the manager holds the master fd **for the session's entire lifetime**. The pty master IS the live session — claude operates on the slave, and closing the master hangs up the slave. Valid master-release points: (1) explicit operator teardown — `/stop` → `stopSession` → `op=archive-release` — and (2) the natural-exit path inside `pty.onExit → handlePtyNaturalExit`.

Inside the scope, `sh -c 'trap "" HUP; exec "$@"' sh <claudeBin> <args...>` keeps claude resident across PTY master-close (SIGHUP trap) and preserves the pid through the exec chain. The earlier `script(1)` wrap and the non-PTY scope primitive (Tasks 552/556/562) are gone; node-pty allocates the TTY directly.

**`/rc-spawn` lifecycle observability (Task 573).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:

| Step | Line shape |
|------|-----------|
| 1 | `[rc-spawn] op=request unitToken=<t> sessionId=<8|new> name=<…|none> mode=<resume|fresh> jsonl=<path|none>` |
| 2 | `[rc-spawn] op=argv unitToken=<t> cwd=<dir> argv=<json>` (inner claude argv; the `systemd-run --scope` wrap is composed by the spawnPty adapter) |
| 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
| 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words) |
| 5 | `[rc-spawn] op=early-exit unitToken=<t> pid=<pid> ranMs=<n> exitCode=<n> signal=<…>` — fires when `pty.onExit` lands before the pid file |
| 6 | `[rc-spawn] op=pidfile-present unitToken=<t> pid=<pid> path=<sessions/<pid>.json> ageMs=<n> bridgeId=<…>` — **terminal success.** The on-disk PID file IS the evidence; no synchronous liveness inference. The tracker remains in `livePtys` for the session's lifetime. |
| 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
| 8 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` — fires when claude exits on its own (operator typed `/quit`, SIGINT in the PTY, crash). |

**Operator-archive release (Task 558).** When the operator clicks End in the UI, `/stop` → `stopSession` → `archiveReleaseTracker` emits a single verified release line:

`[rc-spawn] op=archive-release sessionId=<8> pid=<pid> master-fd=<closed|close-failed err=…> fdBefore=<n> fdAfter=<n> fdDelta=<n> removedFds=<list|none> trackerRemoved=<bool> verified=<bool>`

`verified=true` requires `master-fd=closed` AND `fdDelta>=1` AND `trackerRemoved=true`. `master-fd=close-failed` is logged at error level (`[rc-spawn-error]` prefix) — never swallowed; the next post-archive sweep is the catch-net.

**Cross-arm `[rc-life]` schema.** rc-spawn and rc-daemon emit a shared log shape so the populations can be compared from `server.log` alone. One spawn's full lifeline is `grep <unitToken>`; one surface's signature is `grep 'source=rc-spawn'` or `'source=rc-daemon'`. Success on both surfaces is `op=pidfile-present`; failure is `op=spawn-failed` / `op=early-exit` / `op=wait-pid-failed`. Full schema and operator runbook in [`.docs/rc-life-observability.md`](../../../.docs/rc-life-observability.md). **Measured `remoteBound` (Task 578).** The rc-daemon liveness emit reports `remoteBound` as a measured value flipped by `detectRcHandshake` once the daemon's own post-bind output (`Capacity:` header or an `N of M` capacity line) is seen on the PTY. A daemon that is alive but not registered to Remote Control therefore prints `pidAlive=true remoteBound=false` — previously masked by a hardcoded literal. A class-guard test (`rc-life-literals.test.ts`) scans every `emitRcLife` call across the manager and fails if any status-shaped field is set to a boolean/string literal. The captured PTY output is now dumped on **every** exit (not only fast exits), prefix `exit-output` or `fast-exit-output`, so a late-life prompt-hang is no longer invisible.

**Post-archive fd sweep (Task 558).** Independent of spawn/archive request traffic, the manager runs a 60 s sweep that walks both directions of the master-fd invariant:

- `[fd-audit] op=orphan-master sessionId=<8> pid=<n> archivedAt=<ms> heldSinceArchiveMs=<n> fd=<n|unknown>` — fires per tracker whose row is archived (the leak).
- `[fd-audit] op=orphan-master-escalate sessionId=<8> fd=<n|unknown> heldSinceArchiveMs=<n>` — fires when `heldSinceArchiveMs ≥ 300 000` ms (5 min); strongest leak signal.
- `[fd-audit] op=post-archive-sweep archivedSessions=<n> orphanMasters=<n> openFds=<n> livePtys=<n>` — once per sweep.
- `[fd-audit] op=master-reconcile liveTrackers=<n> liveSessions=<n> archivedWithMaster=<n> orphanLiveSessionsNoMaster=<n>` — once per sweep. `archivedWithMaster>0` = fd leak; `orphanLiveSessionsNoMaster>0` = inverse defect (a live session whose master is gone — it cannot operate). Both are alarms.

The sweep is the catch-net for `master-fd=close-failed` and any future regression that orphans a tracker after archive. The steady-state `archivedWithMaster=0 orphanLiveSessionsNoMaster=0` is itself the signal the sweep ran.

**Manager-shutdown master-audit (Task 558).** On SIGTERM/SIGINT the manager emits `[manager-shutdown] op=master-audit held=<n> liveSessionsClosed=<n>` after walking `livePtys`. `held` is the count of trackers at shutdown entry; `liveSessionsClosed` is the subset whose master was destroyed by this shutdown. This is the data the out-of-scope "does manager restart kill on-device live sessions?" question is decided by — a logged number, not speculation.

`openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.

**PTY lifecycle contract (Tasks 170 + 176 + 260).** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `killSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the in-process tracker entry is removed before the next `/list` or `/events` tick. As of Task 260 the tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).

The metadata pane subscribes to the same /list projection. When an operator clicks End on an alive row, the DELETE returns 200 and the post-mutation refetch decides what happens next: a session that wrote a JSONL surfaces as a dehydrated `status: 'ended'` row (the pane swaps `End session` for `Purge JSONL` plus `Resume`), and a session that never wrote a JSONL (`Turns: 0`) leaves the list entirely (the pane shows a `Session ended without a transcript. Close this pane.` banner with a Close button and no destructive action). The manager's `/list` and `/meta` are the only authorities on post-End state; the client does not pre-empt either response with an optimistic mutation.

**Admin URL hygiene: `?sessionId=<id>` is retained only while `/meta` returns 200.** The shell hydrates `selectedSessionId` from the query-string on mount so a banner-click redirect can re-open a session. The first `/meta 404` (the session has been deleted out from under the slug) strips the query-string via `history.replaceState`, clears the selection, and emits `[admin-ui] stale-session-slug-stripped sessionId=<8-prefix> trigger=meta-404`. A reload from the dead URL therefore starts at base instead of re-resolving a 404.

The Data search panel ranks results by combining vector similarity with keyword (BM25) matching. Each row shows a one-line score breakdown — `vector 0.NN · bm25 0.NN · combined 0.NN` — so you can tell whether a row surfaced because of meaning, exact-keyword match, or both. A bm25 column of `0.00` across every row means your search term wasn't in the keyword index, so ranking fell back to pure vector similarity (this can produce surprising results — the breakdown tells you when to interpret with caution). Above the result list, a chip row shows the unique types in your current results — click one to filter, click again to clear. Click any row to jump straight to that node's neighbourhood in the Graph; from the artefact pane the graph opens alongside chat, from the standalone Data page it opens in place.

## Software Update and Cloudflare Setup

Both flows run on the native Claude Code PTY surface in admin chat (Task 287). There is no in-app upgrade modal and no Cloudflare setup form — the agent invokes the relevant Bash command directly and its stdout streams into chat verbatim.

- **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`) from a shell; HeaderMenu's version row turns sage when `installed === latest`.
- **Cloudflare setup.** Operator asks in chat; the agent invokes `cloudflared` directly via the Bash tool, following the numbered steps in `plugins/cloudflare/references/manual-setup.md`. cloudflared's stdout and stderr stream into the PTY; the OAuth URL printed by `cloudflared tunnel login` is linkified by the terminal so the operator clicks it and authorises Cloudflare in their own browser.

**Mid-turn stream-drop banners.** If a chat turn ends abruptly the bubble shows one of two messages depending on what actually happened. You see "Server is restarting — reconnect will happen automatically." only when the app server itself emits the restart signal — typically during a Software Update or a Cloudflare setup that re-launches the brand service. You see "Lost connection — retrying." when your browser's connection to the Pi dropped mid-stream while the server was still up — typically a flaky Wi-Fi moment or the tunnel hiccupping. Either way the chat resumes once the connection is back; the previously-rendered messages stay on screen so you don't lose context.

**Authorisation** is inherited from the same `canAccessAdmin()` gate that wraps every `/api/admin/*` route.

## AI Content Provenance

When your public agent sends a message to someone — via email, WhatsApp, Telegram, or SMS — the platform automatically includes a brief disclosure that the content was generated by AI. This is transparent and cannot be turned off.

- **Email:** an `X-AI-Generated` header and a footer line are added to every outbound email
- **WhatsApp and Telegram:** a short line is appended to the message body
- **SMS:** a brief suffix is appended when the message is AI-composed

Messages you write yourself (e.g. typing directly in WhatsApp) are not marked — the disclosure applies only to content composed by the AI agent.

## Session Slot Safeguards

{{productName}} runs each chat — yours and every visitor's — as a separate `claude` process on your Pi. Three safeguards keep these processes from piling up:

- **Specialist cap.** Background specialists (`database-operator`, `content-producer`, etc.) are limited to three running at once. If you ask for a fourth while three are still working, the oldest idle one is shut down first. If all three are actively running, the request is rejected with `specialist-cap-reached`.
- **Operator reserve.** Two slots are always held back for *you* — your own chats and one-off tasks. Specialist work that would consume the last reserved slot is rejected with `operator-slots-reserved`. Your interactive chats are never blocked.
- **Idle reaper.** Every 30 seconds the platform looks for specialist processes that started, then went silent without producing any output. After two minutes of silence the platform shuts them down.

All three are tunable via env vars (`CLAUDE_SESSION_MANAGER_SPECIALIST_CAP`, `CLAUDE_SESSION_MANAGER_OPERATOR_RESERVE`, `CLAUDE_SESSION_MANAGER_TOTAL_PTY_CAP`, `CLAUDE_SESSION_MANAGER_RECORDER_IDLE_TTL_MS`); developer details in `.docs/platform.md` § "Claude Session Manager — PTY Slot Safeguards".

If you suspect background processes are piling up, run `grep '\[reaper\]' ~/.{brand}/logs/server.log | tail -50` — each tick logs how many rows it scanned and reaped.

## Tool Permissions

Every install seeds a wildcard `permissions.allow:["*"]` plus `defaultMode:"bypassPermissions"` into both the brand-scoped settings file (`~/.{brand}/.claude/settings.json`) and every account-scoped one (`<install>/data/accounts/<id>/.claude/settings.json`). This stops Claude Code from sending tool calls to its remote auto-classifier, which would otherwise surface a permission prompt in the chat that an unattended session never answers. What each subagent is allowed to use is still controlled by the `tools:` line in its agent file, not by a top-level allowlist. To verify after an install: `cat ~/.{brand}/.claude/settings.json | jq '.permissions'`.
