MaxyDocs
View as markdown →

How Maxy Works

The Short Version

Maxy 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

Maxy 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

Maxy 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 Maxy 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 Maxy's built-in ones (contacts, memory, Telegram).

Roles

Maxy ships twelve roles it can dispatch for specific tasks — like members of your team. You don't need to configure or manage them — Maxy 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 Maxy to call capabilities-here.

RoleWhat it does
Archive Ingest OperatorIngests bulk external archives — Obsidian, ICS, X, Notion — and surfaces schema-mapping ambiguity rather than catching all unmapped relations as :MENTIONS.
Citation AuditorAudits :TimelineEvent rows for missing citations and writes either citations directly or a CitationProposal stub.
Coding AssistantRuns 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 RewriterRecomputes a node's compiledTruth (and public twin where applicable) from its 90-day timeline plus optional operator hints.
Content ProducerProduces visual output from your graph: generates images, renders pages to PDF, and hosts static websites you upload as a zip.
Database OperatorExecutes graph writes on admin's behalf when delegated via the Task tool — admin names each write, the specialist runs it.
LibrarianOwns foreground ingest of documents, conversation transcripts, and external archives into the memory graph.
Personal AssistantHandles 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 ManagerManages your tasks, projects, sessions, and workflows: linking work to people and goals, and keeping everything organised.
Public Session ReviewerReads a gated public-agent transcript and dispatches database-operator for each per-visitor memory write.
Research AssistantResearches topics online, manages your knowledge graph, and produces supporting visuals.
Typed Edge ClassifierReads recently-written prose nodes and writes typed edges from a closed allowlist.

Roles are installed during setup and listed when Maxy 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

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

The recording loop. Maxy dispatches database-operator inline at its own discretion when a write must complete before the assistant response ends. The full graph-completeness sweep runs at session end: when you type /end, the session-end-retrospective.sh Stop hook blocks the close until Maxy walks the session for any node, edge, or commitment that was discussed but not written in-flight, then dispatches one database-operator Task per candidate write.

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 (the database-operator recorder, 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 (the per-turn recorder case) emits row-removed against the unindexed sessionId; a JSONL deletion against an already-unindexed row also emits row-removed. The recorder branch is what reconciles transient hidden spawns — without it, ghost rows persist after the recorder 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" — and the turn-recorder loopback path forwards its own initialMessage. 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 — /stopstopSessionop=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:

StepLine shape
1`[rc-spawn] op=request unitToken=<t> sessionId=<8
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, /stopstopSessionarchiveReleaseTracker 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. 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

Maxy 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'.