---
name: platform-architecture
description: Use when grounding any documented-surface claim about what SiteOffice ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
content-hash: sha256:f702779c4fa4f114c6f74c80972bdd64f72caa4541ef5e07cd8115870741dbb4
brand: siteoffice-code
product-name: SiteOffice
---

# SiteOffice platform architecture (catalogue)

This skill is the install catalogue — every surface the platform can ship. It is not evidence of what is installed on the current account. Cite for documented surface. For 'what is installed here' / 'what can you do', call `capabilities-here` and read `account-manage`; do not infer install state from this body.

This skill is generated. Its body is the concatenated source markdown for every public SiteOffice docs page, emitted by `docs/scripts/copy-docs.mjs` as `docs/public/llms-full.siteoffice-code.txt` and assembled here by `maxy-code/scripts/assemble-architecture-skill.mjs`. Do not hand-edit — the drift gate in `platform/scripts/check-architecture-skill-no-drift.mjs` will fail CI.

When you load this skill to ground a factual claim, cite the `Source:` URL printed above the block you drew from. The URL is the canonical public docs page (https://siteoffice.online); relay it as a markdown link in your reply. Training-data recall is not a permitted source — the body below is.

---
# Maxy Documentation — full corpus

Concatenated source markdown for every public Maxy docs page. Pages are separated by `---` and labelled with their canonical URL.

---
# Getting Started
Source: https://docs.getmaxy.com/getting-started.md

# Getting Started with Maxy

## What Maxy Is

Maxy is your Operations Manager — an operations layer that runs on a device on your premises. It plays four roles: follows through on your commitments, responds to your customers at any hour, handles your finances (quotes, invoices, chasing), and manages your picture — what's overdue, what's at risk, what needs a decision.

You don't adopt a new system. You just talk, and the organisation happens. Maxy connects to your services — WhatsApp, Telegram, email, your contacts, your calendar — and acts proactively. It remembers context across conversations and takes action on your behalf.

Because Maxy runs locally, your data stays in your home. It never passes through someone else's cloud.

## The Two Interfaces

**Admin (you)** — accessed at your local address (e.g. `maxy.local:19200`) or remotely via your Cloudflare domain. The admin interface is protected by a PIN. This is where you manage Maxy: configure settings, manage contacts, review activity, and have full conversations. The admin agent has access to all your plugins and can take action.

**Public (visitors)** — anyone who reaches your public URL gets the public agent. It handles product enquiries, collects prospect details, and answers questions about your business. It cannot read or write your private data.

## First Power-On (New Device)

If your device has no WiFi configured and no ethernet cable connected, it creates a temporary WiFi network for setup:

1. Power on the device and wait about 60 seconds
2. On your phone, look for a WiFi network called **{ProductName}-Setup** (e.g. `Maxy-Setup`)
3. Connect to that network — a setup page opens automatically
4. Select your home WiFi network from the list and enter the password
5. The device connects to your WiFi and the temporary network disappears
6. Your phone automatically reconnects to your home WiFi

After WiFi is configured, open your browser and go to `{hostname}.local:19200` (the setup page shows this address).

If you already have ethernet connected, the temporary WiFi network does not appear — go directly to the admin interface.

## First Run

When you first open the admin interface:

1. Set your PIN — this protects access to the admin interface
2. Connect to Claude — Maxy will guide you through connecting to your Claude account
3. Enter your PIN to log in
4. Maxy walks you through onboarding: choosing which plugins to activate, connecting to WiFi (skip if already configured via the setup network above), setting up remote access, and configuring your account

This setup is resumable — if you close the browser mid-setup, Maxy picks up where you left off next time.

After install, a live admin terminal is available inside the Software Update window — your Pi's shell, accessible through the admin UI, for upgrades and any other shell work without needing to SSH.

## How to Use Maxy

Conversation is the only interface. Type or speak what you need:

- "Add John Smith to my contacts, he's a potential client from the conference"
- "Schedule a call with Sarah for Thursday at 2pm"
- "What did I last discuss with Tom?"
- "Send a Telegram message to the team: standup in 10 minutes"
- "Create a one-pager PDF about our new product launch"

Maxy understands plain language. You don't need to learn commands or navigate menus.

### Voice Notes

When the text field is empty, a microphone button appears in place of the send button. Tap it to record a voice note — speak naturally and tap send when done. Maxy transcribes your voice note and responds to what you said, the same as if you had typed it. You can also pause and resume recording, or tap the trash icon to discard and start over.

Voice recording requires a secure connection (HTTPS). When accessing Maxy over the local network via HTTP, use the tunnel URL for voice notes.

You can also drop, paste, or pick an audio file (`.opus`, `.ogg`, `.m4a`, `.mp3`, `.wav`, `.webm`) into the chat composer — for example a voice note forwarded from WhatsApp. The file is transcribed the same way the in-browser recording is, and only the transcript reaches Maxy; the audio itself is discarded after transcription.

## What Maxy Remembers

Maxy maintains a memory graph of everything important: contacts, conversations, preferences, relationships, and context. When you tell Maxy something, it stores it. When you ask about something later, it retrieves it.

You can always tell Maxy to remember or forget specific things: "Remember that I prefer morning calls" or "Forget what I said about the Johnson account."

## Reaching Your Data When Maxy Is Unavailable

If the AI is ever unreachable — network outage, API provider down — you can still access your own data through the **Data** item in the admin header menu. It opens a page with two panels:

- **Graph search** — type a keyword to search your knowledge documents, sections, and notes directly in Neo4j. No AI involved.
- **Files** — browse everything under your install's `data/` folder. Click a folder to open it, a file to download it. Use **Upload** to drop new files into `data/uploads/`.

Everything on this page works without calling any AI service. It's there so your data is never locked behind an agent that can't respond.

## Getting Help

Ask Maxy anything. If you want to know what it can do, just ask: "What can you help me with?" or "How do I set up Telegram?"

---
# How Maxy Works
Source: https://docs.getmaxy.com/platform.md

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

| 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 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 on demand: when you invoke the `/insight` admin skill, it runs a four-pass instruction and Maxy 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

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

---
# Plugins Guide
Source: https://docs.getmaxy.com/plugins-guide.md

# Plugins Guide

## What a Plugin Is

A plugin extends what Maxy can do. Each plugin adds a focused capability — contacts management, Telegram messaging, scheduling, email, research. Plugins are modular: you enable only what you need.

Maxy's own capabilities are plugins too. Marketplace plugins (like Stripe) work the same way — Maxy manages all of them through conversation.

The tables below are the install catalogue — every plugin the platform can ship. They are not evidence of what is enabled on the current account. For the live install set, ask Maxy to call `capabilities-here`.

## Plugin Groups

### Core (always active)

These are part of Maxy's foundation and cannot be disabled:

| Plugin | What it does |
|--------|-------------|
| `admin` | Platform management — system status, account settings, logs, session control. Also hosts the cross-cutting `plainly` skill: every text-producing agent (admin, public, every specialist) applies a plain-English precision pass to prose returned to humans, as a prime-directive prerogative. Agent-to-machine payloads (image-generate prompts, memory-write arguments, cypher) pass through verbatim. This is a prompt-level skill contract, not a hook: each agent's IDENTITY loads `skill-load skillName=plainly` on its first text-producing turn and applies the pass thereafter. Hosts the `superpowers-sprint` skill: structured sprint workflow built on the `superpowers` and `code-review` upstream plugins, dispatched on "run a sprint" or any `.tasks/NNN-*.md` invocation. |
| `memory` | Graph memory — search, write, reindex, and ingest knowledge |
| `browser` | Headless browser rendering — `browser-render` runs a JavaScript-heavy page in the device's Chromium and returns its rendered DOM. The JS-rendering leg of retrieval: WebFetch (summary) / `url-get` (verbatim, server-rendered) / `browser-render` (JS-rendered). |
| `maxy-guide` | User guide and platform documentation (this plugin) |
| `cloudflare` | Cloudflare Tunnel — remote access via your custom domain |
| `scheduling` | Calendar and scheduling — events, appointments, recurring triggers. Any activity involving time (date, timestamp, day of week, month, duration) routes through `time-resolve` first. Two read-only tools (`current-datetime`, `time-resolve`) are always available to every public agent regardless of enabled plugins. |
| `email` | Agent email account — setup, read, send, reply, search, auto-respond |
| `tasks` | Task lifecycle — create, update, list, relate, complete |
| `workflows` | Persistent named workflows — reusable instruction sets |
| `contacts` | CRM contact management — create, lookup, update, list |
| `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. |
| `url-get` | Faithful page retrieval — fetches a server-rendered page, writes a verbatim markdown copy to an account-scoped reference file (no model in the path, so no copyright refusal), and returns the cleaned page text (capped) plus the file path. No summary and no subprocess: a caller that wants a summary invokes url-get from a delegated subagent. Use instead of WebFetch when a faithful copy is needed (e.g. ingesting your own published writing). |

### Maxy Plugins (user-selectable)

These are enabled during onboarding and can be added or removed at any time. Some plugins enhance a specific specialist role — when enabled, that specialist gains additional capabilities.

| Plugin | What it does | Enhances |
|--------|-------------|----------|
| `business-assistant` | Customer enquiries, scheduling, quoting, invoicing, daily briefings | Personal assistant |
| `sales` | Buying signal detection, closing techniques, objection handling | Personal assistant |
| `deep-research` | Structured multi-source research — query decomposition, source evaluation, citations | Research assistant |
| `projects` | Structured project execution — phased sprints, investigations, reviews, retrospectives | Project manager |
| `telegram` | Telegram bot — BotFather setup, messaging, channels | Personal assistant |
| `whatsapp` | WhatsApp messaging, pairing, and conversation browsing | Personal assistant |
| `replicate` | Image generation — three models for photorealistic, design, and fast draft images | Content producer, Research assistant |
| `linkedin-import` | Import a LinkedIn Basic Data Export — Profile and Connections today, more CSVs as references land | Database operator |
| `notion-import` | Import a Notion workspace export (markdown + CSV) — pages, databases, hierarchy, attachments, schema-bounded relations, `@person` mentions account-filtered | Database operator |
| `obsidian-import` | Import an extracted Obsidian vault — pages map to `:KnowledgeDocument`, wikilinks resolve to intra-vault pages or existing entities, tags become `:DefinedTerm`, embedded images become `:DigitalDocument`. Two-phase tool (dry-run → operator disambiguation → commit). | Database operator |
| `x-import` | Import an X (Twitter) Basic Data Export — tweet stream renders as one chronological transcript and ingests as a single `:KnowledgeDocument` (`source='x'`); each DM `sessionId` ingests as one `:ConversationArchive` (`source='x-dm'`, keyed on `conversationIdentity`) via `conversation-archive-ingest.sh`. Mentions, replies, and quote-tweet authors resolve to `:Person` on lowercased `xHandle`; every handle and DM senderId confirms against existing nodes (no auto-create). Per-thread KD granularity and `:Post` / `:DirectMessage` labels are explicitly rejected. | Database operator |
| `substack-import` | Import a Substack "Export your data" archive — per-essay `:KnowledgeDocument {kind:'substack-post'}` via librarian/document-ingest with synthetic stable `attachmentId = "substack-post-${substackPostId}"` (survives Substack edits); one `:KnowledgeDocument {kind:'substack-subscriber-roster'}` per import run with `:MENTIONS {mentionContext:'substack-subscription', tier, totalOpens, totalClicks, lastOpenedAt, lastClickedAt, engagementWindowDays}` to each subscriber `:Person` MERGEd on `(accountId, email)`. Engagement aggregates parsed from `email_activity.csv` (or `subscriber_activity.csv` / `emails.csv`); overwrite-on-reimport. No new label, no new edge type, no new graph writer. Images attach via canonical `:HAS_ENCLOSURE` (or `:MENTIONS` fallback). Bulk-gate at >200 posts or >2000 subscribers. | Database operator |
| `memory/skills/conversation-archive` | Source-agnostic conversation transcript ingest. One skill for WhatsApp `_chat.txt`, Telegram, Signal, LinkedIn DMs, Zoom transcript, meeting minutes, iMessage, Slack, X DMs — `--source <enum>` selects the per-source normaliser. Single Bash entry — `bash platform/plugins/memory/bin/conversation-archive-ingest.sh <archive> --source <enum> --participant-person-ids <csv> --scope <admin\|public>` — runs normalise → operator-confirms owner + every distinct sender (owner derived from env via Cypher, no flag) → sessionize at the fixed 8h gap → emit one JSON line carrying prepared sessions (turn-attributed text + per-session cursor). The dispatched specialist iterates the sessions in-turn, produces a typed-section JSON chunking for each, and calls the `memory-ingest` MCP tool with `conversationIdentity` set (writes `:ConversationArchive`, source=<enum>) once per session — chunks + cursor advance commit atomically inside one Cypher transaction, so a kill mid-archive resumes from the next session on re-issue without re-classifying anything already written. Re-imports are delta-append. Auto-creating participants is forbidden — any sender outside the operator-confirmed closed set LOUD-FAILs with `parser-miss`. Distinct from the live `whatsapp` plugin (Baileys). | Database operator |
| `memory/skills/conversation-archive-enrich` | Phase 2 for any named `:ConversationArchive` — source-agnostic per-row insight derivation. Operator-triggered (never auto-fires on Phase 1 completion). Walks the parent's `:Section` chunks in pages via the read-only MCP tool `mcp__plugin_memory_memory__conversation-archive-list-chunks`; the dispatched specialist reads each chunk in-turn and emits claims under the four-kind contract (`mention`, `task`, `preference`, `observed-relationship`); the skill hands those claims to `mcp__plugin_memory_memory__conversation-archive-derive-insights` for per-kind cypher emission, then runs the per-row operator gate (`wire / skip / reject`). Idempotent on `(elementId(chunk), kind, contentHash)` — re-runs collapse identical claims. Confidence floor is a hedging-avoidance instruction the skill embeds in the specialist's per-chunk prompt, not a numeric post-filter; per Task 433 the LLM step runs in-turn from the dispatched specialist rather than as a server-side OAuth round-trip. | Database operator |

### Claude Official (marketplace)

Third-party plugins from the Claude marketplace:

| Plugin | What it does |
|--------|-------------|
| `stripe` | Live access to payment and business data |

### Claude Anthropic Verticals (marketplace, opt-in)

Optional plugins from Anthropic's vertical marketplaces. The installer registers `claude-for-financial-services` and `knowledge-work-plugins` so the install commands work; none are auto-installed. You pick each deliberately during first-run onboarding (Step 1) or by name at any time.

| Plugin | Marketplace | What it does |
|--------|-------------|-------------|
| `kyc-screener` | `claude-for-financial-services` | Parses onboarding documents, runs a rules engine, flags gaps. Outputs are draft work product for human review — your compliance specialist owns sign-off. Relevant to UK estate agents under MLR 2017. |
| `meeting-prep-agent` | `claude-for-financial-services` | Briefing pack before every client meeting, FSI-flavoured templates. Overlaps with the business-assistant calendar-prep flow — choose one deliberately. |
| `pdf-viewer` | `knowledge-work-plugins` | Live interactive viewer to view, annotate, and sign PDFs — mark up contracts, fill forms, stamp approvals, place signatures, download the annotated copy. Click-through replaces conversation for this surface (v0.2.0, different shape from chat-driven skills). |

Install verbatim:

- `claude plugin install kyc-screener@claude-for-financial-services`
- `claude plugin install meeting-prep-agent@claude-for-financial-services`
- `claude plugin install pdf-viewer@knowledge-work-plugins`

### Premium Plugins

Brand decides which premium plugins ship. The brand's `shipsPremiumBundles` field in `brand.json` is the gate; three shapes are supported:

- **omitted / false** — ship nothing from `premium-plugins/` (the legacy Maxy default).
- **`true`** — ship every bundle under `premium-plugins/*` (Real Agent / `realagent-code`).
- **`["bundle-a", "bundle-b"]`** — ship only the named bundle directories (Maxy Code's `["venture-studio"]`). Names with no matching directory on disk are silently dropped; non-allowlisted bundles are stripped from any account that was previously stamped with them.

There is no per-account purchase record; the brand decides the shipping set.

| Plugin | Type | What it does | Public agent |
|--------|------|-------------|-------------|
| `teaching` | Skills | Interactive tutoring, lesson planning, and study pack generation from your knowledge base | Yes — all 3 skills serve students and parents |
| `real-agent` | Bundle (13 sub-plugins) | UK estate agency skills — sales, listings, vendor management, buyer management, lead generation, coaching, business operations, teaching, Loop CRM (five value pillars: auto-respond, viewing lifecycle, pipeline mining, listings prospecting, maintenance & preferences), PropertyData market analytics (valuation, sold prices, £/sqft baselines, £/sqft growth, demand-rent, area risk, planning precedent, UPRN matching, property-type distribution), gov.uk EPC floor-area lookup, property brochures, social-share image cards, A4 market reports, and single-address preval packs (full UK address → 4-page A4 PDF covering valuation, area, and demand). 3 specialist roles (negotiator, valuer, compliance) | 4 sub-plugins (estate-sales, buyers, estate-coaching, estate-teaching) |
| `writer-craft` | Skills + Agent | Manuscript review and writing craft — story architecture, reader engagement, prose craft, editorial practice, and multi-level review | No — writing craft serves the author |
| `venture-studio` | Skills + Agent | Founding-a-business workflow — office-hours discovery, brand pack, zero-to-prototype validation, and the full investor data room (business plan, prospectus, term sheet, deck blueprint, A4 print pipeline). Pre-seeds a `Project` with one `Task` per artefact so nothing gets forgotten. | No — founder-facing only |

**How it works:** Every boot Maxy delivers the brand's premium plugins from staging into `platform/plugins/` and stamps `enabledPlugins` against what is actually on disk. No conversation needed — the brand's full set is active from the first turn after install. Updates and reinstalls re-deliver from staging.

Some premium plugins are **bundles** — multiple sub-plugins shipped under one directory in `premium-plugins/`, each independently activatable. For example, Real Agent ships 10 sub-plugins covering different aspects of estate agency work. They are all enabled by default. Sub-plugins you don't want active can be turned off individually with "disable <name>"; enabling or disabling individual sub-plugins does not affect the others.

If you ask Maxy about a tool from a plugin your brand does not ship (for example, a Maxy install asking about a Real Agent Loop CRM tool), Maxy responds with a structured `<tool-surface-error>` envelope naming the missing plugin and the remedy, rather than improvising with a generic alternative.

**Public agent embedding:** Premium plugins marked as public-eligible have their full content (skills and reference knowledge) embedded in public agent prompts. This means a public agent for a Real Agent member can handle buyer enquiries, book viewings, deliver coaching content, and onboard new applicants — all powered by the premium plugin's domain knowledge. Plugins marked admin-only (listings, vendors, leads, business) are only available to the account owner's admin agent.

Some premium plugins include specialist helpers that Maxy can dispatch for specific tasks (e.g. the writer-craft plugin includes a manuscript reviewer). These are activated automatically when the plugin is enabled.

Some premium plugins include pre-built public agent templates — ready-made configurations for customer-facing agents. When you enable the plugin, Maxy shows you what templates are available and offers to create agents from them. You review and approve every file before the agent is created. The template is a starting point — you can edit the identity, personality, plugins, and settings to make it yours. The result is a standard public agent, indistinguishable from one you created from scratch.

Some premium plugins ship pre-built workflows that are created when the plugin is enabled. These workflows are fully yours — you can inspect, edit, run, and manage them through conversation, exactly like workflows you create yourself. The plugin provides the starting point; you own the result.

**If a premium plugin ever stops working** — `documents`, `teaching`, anything else you've paid for — and Maxy responds as if it doesn't have those tools, the platform's health check (`/api/health.missingPlugins`) will name the affected plugin. Tell Maxy "deliver the {{plugin}} plugin" — it re-runs the same delivery step that fires automatically at session start. If the plugin isn't in the device's staging area, re-run the installer for this brand.

## Choosing Plugins

During first-time setup, Maxy presents a plugin selection screen where you choose which plugins to activate. Core plugins are pre-selected and locked. Recommended plugins are pre-selected but optional. You can change your mind later.

## Adding or Removing Plugins

Tell Maxy:

- "Enable the Telegram plugin"
- "Add the Stripe plugin"
- "Disable the deep-research plugin"

Maxy handles the installation or removal. If the plugin requires any setup (API keys, bot tokens, configuration), Maxy will walk you through it.

## Viewing Your Plugins

Ask Maxy: "What plugins do I have?" or "List my plugins."

## Operator-Authored Plugins (skill-builder output)

Skills you create at runtime through the admin `skill-builder` skill are saved on disk as their own plugin under `data/accounts/{accountId}/plugins/{pluginName}/`. The admin agent calls `mcp__plugin_admin_admin__store-skill`, which composes `PLUGIN.md` (on first call) and `skills/{skillName}/SKILL.md` plus any reference files. The agent supplies `pluginName`, `skillName`, `description`, `publicEmbed`, `body`, and optional references — the path is computed by the tool, never by the agent.

These operator-authored plugins survive reinstall because the installer's wipe zone excludes `data/`. At admin session start the platform mirrors `data/accounts/{accountId}/plugins/*` into the runtime plugins directory so the same `parsePluginFrontmatter` / `assemblePublicPluginContent` / `loadEmbeddedPlugins` loaders that read shipped and premium plugins also pick up operator-authored ones — no special-case loader path. The admin agent sees every operator skill by default; per-skill `publicEmbed: true|false` controls which skills surface to the public agent.

To edit an operator-authored skill later, ask Maxy to update it — the admin agent re-runs `store-skill` for the same `pluginName`/`skillName` and the new content overwrites in place. To remove one, delete the directory under `data/accounts/{accountId}/plugins/{pluginName}/skills/{skillName}/` (or the whole plugin) — the next session start re-mirrors the remaining skills only.

`pluginName` collisions with shipped plugin names are refused by `store-skill` with a structured error. See [.docs/agents.md](../../../../.docs/agents.md) § "Operator-authored skills as plugin files" for the full contract.

## Brand Templating (for plugin and skill authors)

Skill content, plugin manifests, agent templates, and reference files reference the operator-visible brand name only via the literal `Maxy` placeholder. The platform substitutes from `brand.json.productName` at read time — Maxy installs render `Maxy`, Real Agent installs render `Real Agent`, all from the same source content.

**Author rule:** never write the literal string `Maxy` (or any brand name) in shipped skill, plugin, or template content. Use `Maxy` whenever the operator should see the brand name. The audit grep `grep -rn "\bMaxy\b" platform/plugins/admin/skills/ platform/plugins/*/skills/ platform/templates/agents/` must return zero matches; a literal brand name is a defect, not a stylistic choice.

The runtime substitution happens at every read site that flows content into a system prompt or operator-visible UI: the admin agent's `plugin-read` tool (references + `PLUGIN.md`), the `skill-load` tool (SKILL.md by skill name — one-call resolver+reader, the canonical primitive for SKILL.md), the public agent's recursive plugin assembly, and `IDENTITY` / `SOUL` / `AGENTS` / `KNOWLEDGE` markdown reads. Missing or empty `productName` hard-fails — there is no fallback to a default brand string. See [.docs/agents.md](../../.docs/agents.md) § "Brand templating" for the full contract.

## MCP Plugin Observability (for plugin authors)

Every `console.error` line from a plugin's MCP server can be teed into the per-conversation agent stream log so a single `logs-read` call returns one conversation's full timeline — agent events and plugin diagnostics interleaved in chronological order.

**Opt-in (one line at the top of the MCP server's entry file):**

```typescript
import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
initStderrTee("your-plugin-name");
```

After this, every `console.error("[your-tool]...")` from any tool in the plugin appears as `[<iso-ts>] [mcp:your-plugin-name] [your-tool]...` in the per-conversation stream log `claude-agent-stream-{sessionId}.log`, alongside the usual agent events. The raw per-server file `mcp-your-plugin-name-stderr-{date}.log` is still produced for deep-dive grep.

**Premium plugins.** Source lives at `premium-plugins/<bundle>/plugins/<name>/mcp/src/` — deeper than platform plugins, so the source-relative import to `platform/lib/mcp-stderr-tee/dist/index.js` uses more `../` segments. The bundler rewrites the compiled output to the canonical `../../../../lib/mcp-stderr-tee/dist/index.js` at staging time and ships `platform/lib/mcp-stderr-tee/{dist,package.json}` into `premium-plugins/<bundle>/lib/mcp-stderr-tee/` so the import resolves at deployed depth. The bundler fails loudly if `platform/lib/mcp-stderr-tee/package.json` is missing (it must pin `type` so install-location parent walks cannot mis-classify the dist file) or if any lib referenced by a rewritten import has no source dist.

**How the tee decides which file to write to:** the platform sets `STREAM_LOG_PATH` as an environment variable on every MCP server spawn, pointing to the conversation-scoped stream log. The MCP server does not know about conversations — it just trusts `STREAM_LOG_PATH`. Multiple concurrent conversations produce multiple concurrent MCP server processes, each teeing to its own file; no cross-conversation leakage.

**Bash commands stream straight into the PTY.** Maxy Code's admin and public chat run on the native Claude Code PTY (Task 287). The per-conversation server-side stream log that the retired web-UI dispatcher tailed is gone; agent-invoked Bash commands (including direct `cloudflared` invocations for Cloudflare setup — Task 288) print their stdout and stderr directly, and the PTY renders the output in chat verbatim.

**Retrieve MCP diagnostic lines for a conversation:**

- All servers: `logs-read { type: "system", sessionId: "..." }` → grep `[mcp:<name>]` on the returned stream log.
- One server raw feed: `logs-read { type: "mcp" }` → tails the most recent `mcp-<name>-stderr-*.log` (per-plugin, not per-conversation).

**Tee-state markers** land in the stream log: `[platform] [mcp-tee-attach] server=<name> streamLogPath=...` when the tee wires up, `[platform] [mcp-tee-skip] server=<name> destination=... reason=...` when a destination fails (missing `LOG_DIR`, unwritable path, `STREAM_LOG_PATH` not set, etc.), `[platform] [mcp-tee-detach] server=<name>` on graceful shutdown. If a server invoked tools but no `[mcp:<name>]` lines appear in the conversation's log, look for the skip marker first.

**Main-subprocess stderr.** The same teeing pattern applies to the main Claude Code subprocess's stderr — every line lands in the per-conversation stream log as `[subproc-stderr] …`, with lifecycle markers `[subproc-stderr-tee-attached] pid=…` and `[subproc-stderr-tee-detached] pid=… bytes=N lines=N`. A `bytes=0 lines=0` detach means the tee was attached but the subprocess emitted nothing on stderr — which is the normal state today, because the Claude Code CLI is a bundled Bun runtime binary that does not honour Node's `NODE_DEBUG` env var. The platform records this explicitly with one line per spawn: `[subproc-debug-unavailable] reason=bundled-bun-binary-ignores-node-debug pid=… cli=claude`. A reader who finds a `[spawn]` without these markers should treat that as a regression of the tee infrastructure, not as silence.

## Failure-path observability contract (earlier platform fixes + earlier platform fixes)

The `initStderrTee` wrapper writes to the per-conversation stream log and per-server raw file via `createWriteStream` — async, buffered. Any diagnostic `console.error(…)` followed by an immediate `process.exit(…)` is lost: the event loop never drains the WriteStream before the process terminates. Same race for any synchronous module-load throw: Node's uncaught-exception handler writes the stack to raw fd 2 and exits before the patched async stream flushes. The platform's `[mcp-init-error] tail="(no stderr file)"` line — operationally useless — is the public symptom of this race.

**Two layers now close the gap, each load-bearing on its own:**

1. **Plugin-side sync-write discipline.** Plugins that call `process.exit` during module load (rare — `graph-mcp` is the in-tree example; it spawns a child at boot to proxy upstream stdio) use `fs.appendFileSync` at every named exit path to guarantee the cause lands in both log destinations before exit. Lines follow the `[mcp:<name>] [<plugin-prefix>] <cause>` format so existing `grep '[mcp:<name>]'` investigator paths work. Each destination is wrapped in its own try/catch — an unwritable log must not mask the primary failure. This is the discipline propagated to any plugin author who knows their failure paths.

2. **Parent-side `mcp-spawn-tee` wrapper.** Every node-based core MCP server is spawned via the `lib/mcp-spawn-tee` wrapper rather than `node <entry>` directly. The wrapper spawns the real entry with `stdio: ['inherit', 'inherit', 'pipe']` and writes child stderr chunks to `${LOG_DIR}/mcp-${name}-stderr-<date>.log` via `appendFileSync` while passing the same chunks through to its own stderr (Claude Code's consumer is unchanged). Synchronous `appendFileSync` survives `process.exit`, so the per-server file captures even (a) module-load throws before `initStderrTee` runs, (b) `MODULE_NOT_FOUND` on the entry script itself, and (c) anything else a plugin author missed. The wrapper writes `[mcp-spawn-tee-attached] server=<name> pid=<n>` on attach and forwards SIGTERM/SIGINT to the child. This is the layer that makes capture independent of plugin discipline. Playwright stays unwrapped because it spawns via `npx`, not `node`.

A third layer closes the same gap from the platform side: when `claude-agent.ts` observes an `init` event with any MCP server reporting `status:"failed"`, it reads the last 512 bytes of `${LOG_DIR}/mcp-<name>-stderr-<date>.log` and emits `[mcp-init-error] server=<name> tail=<quoted>` into the stream log. Absent file → `tail="(no stderr file)"`; empty file → `tail="(empty)"`. With the spawn-tee wrapper now interposing on every core MCP, `tail="(no stderr file)"` post-Task-743 means the wrapper itself is broken — file follow-up.

**Signal inventory after a failed session:** `[init] FAILED MCP servers: <names>` (names), `[mcp-init-error] server=<name> tail=…` (cause for each, from the platform's tail probe), `[mcp-spawn-tee-attached] server=<name> pid=<n>` (proof the wrapper attached), `[mcp-spawn-tee-exit] server=<name> code=<n>|signal=<s>` (proof the wrapper saw the exit), and optionally `[mcp:<name>] [<plugin>] …` from plugin-side sync-writes. Their union gives the investigator three independent sources for the same failure.

**Boot-smoke as publish-time gate.** The memory MCP carries `scripts/boot-smoke.sh` that spawns `dist/index.js` with stub env, sleeps 2s, asserts `kill -0 <pid>`, and reports `[boot-smoke] memory ok|FAILED tail=<n-lines>`. Wired to `prepublish` in `plugins/memory/mcp/package.json`. The pattern is propagatable to other plugin MCPs — it's deliberately not generalised yet because each plugin's stub-env requirements differ (memory needs ACCOUNT_ID + PLATFORM_ROOT + NEO4J_URI + SESSION_ID; others differ).

---
# Install Overview
Source: https://docs.getmaxy.com/install.md

# Installing Maxy Code

Maxy Code installs from one npm one-liner on every supported host. The host you choose determines the supervisor (systemd vs launchd), the Cloudflare flow (provisioned vs operator-opt-in), and the VNC requirement (Pi/cloud VM only).

| Host | Doc | Supervisor | Cloudflare tunnel | Hostname flag |
|---|---|---|---|---|
| Raspberry Pi 5 (16GB) on Ubuntu Server 24.04 | [pi.md](pi.md) | systemd user-service | provisioned post-install via `cloudflared tunnel login` in the Pi's VNC browser | `--hostname` required |
| Hetzner Cloud CAX31 (16GB arm64) on Ubuntu 24.04 | [hetzner.md](hetzner.md) | systemd user-service | provisioned post-install via `cloudflared tunnel login` in a noVNC browser reached over SSH port-forward | `--hostname` required |
| macOS 14+ on Apple Silicon | [macos.md](macos.md) | launchd LaunchAgent | not provisioned; operator runs `cloudflared tunnel login` post-install if they want public reach | `--hostname` optional |

The installer source is `maxy-code/packages/create-maxy-code/`. The same package is published as `@rubytech/create-maxy-code` and `@rubytech/create-realagent-code`; the publisher rewrites the package name at bundle time per brand.

Engineers reading the codebase should also see [../deployment.md](../deployment.md) for call-site detail (which branch in `index.ts` does what, log-line shapes, branch-by-branch decisions).

---
# macOS Install
Source: https://docs.getmaxy.com/install/macos.md

# Installing Maxy Code on macOS

End-to-end install for a fresh macOS account on Apple Silicon (M-series). Every command is copy-pasteable and uses auto-yes flags so nothing prompts interactively.

The doc is brand-aware. Examples use the default brand `maxy-code`; substitute `realagent-code` (or any other brand under `maxy-code/brands/`) wherever you want a parallel install. Each brand is fully isolated — its own persist directory, its own LaunchAgent, its own admin UI port, its own `CLAUDE_CONFIG_DIR`.

> Pi install: see [pi.md](pi.md) for the Raspberry Pi flow.
> Other hosts and engineering detail: see [index.md](index.md) and [../deployment.md](../deployment.md).

## Requirements

- macOS 14 (Sonoma) or newer. The installer refuses to run on 13 and below; you will see `[create-maxy] platform=darwin macos=… — refusing: macOS 14+ required`.
- Apple Silicon (M1/M2/M3/M4). Intel Macs are not part of the supported matrix — the installer pins `node@22` from Homebrew's Apple Silicon cellar (`/opt/homebrew`) and other paths assume that prefix.
- Admin (sudo) account. The installer asks for your password once when it sets the system hostname via `scutil`; everything else runs unprivileged.
- A working internet connection — Homebrew, npm, and Cloudflare endpoints are all reached during install.

## 1. Install Node 22 via Homebrew

```bash
# Homebrew (skip if already installed)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Node 22 — pinned formula
brew install node@22
brew link --overwrite --force node@22

# Verify (must be 22.6 or newer)
node --version
```

Node from the system PATH must resolve to `/opt/homebrew/opt/node@22/bin/node`. If `which node` points anywhere else, fix the PATH before continuing — the installer reads node from `PATH` and a 20.x binary will trip the engines check.

## 2. Run the installer

The default brand is `maxy-code`. Run from any directory; the installer creates and writes everything under `$HOME/.maxy-code`.

```bash
npx -y @rubytech/create-maxy-code@latest
```

That command:
- creates the persist directory `$HOME/.maxy-code/` (logs, config, plugin state, the `.claude/` config tree, browser profile);
- exports `CLAUDE_CONFIG_DIR=$HOME/.maxy-code/.claude` for every Claude Code invocation it spawns (default `~/.claude` is the wrong tree on a multi-brand machine);
- builds the platform payload bundled in the npm tarball;
- writes a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.maxy-code.plist` and loads it with `launchctl bootstrap gui/$UID`;
- prints the admin UI URL when the supervisor reports the server is listening.

The full install log lands at `$HOME/.maxy-code/logs/create-maxy-<timestamp>.log`. Every phase line is prefixed `[create-maxy] phase=… brand=… platform=darwin` — that's the canonical signal if you want to attach an install log to a support request.

### Optional: `--hostname`

By default the installer leaves your existing macOS hostname alone and serves the admin UI at `http://<your-existing-LocalHostName>.local:<port>`. If you want a dedicated name on the LAN, pass `--hostname`:

```bash
npx -y @rubytech/create-maxy-code@latest --hostname maxy
```

That triggers three `sudo scutil --set` calls — `HostName`, `LocalHostName`, `ComputerName` — and the admin UI then resolves at `http://maxy.local:<port>` from any device on the same Bonjour/mDNS network. The flag is the only path that mutates system hostname state; omitting it preserves whatever you had.

### Installing a second brand

To run, for example, `realagent-code` alongside the default install, repeat step 2 with that brand's package:

```bash
npx -y @rubytech/create-realagent-code@latest
```

The persist directory becomes `$HOME/.realagent-code`, the LaunchAgent becomes `com.rubytech.realagent-code`, and the admin URL switches to `http://realagent-code.local:<port>`. Every brand has its own isolated tree — there is no shared state, and `CLAUDE_CONFIG_DIR` is always `$HOME/.<brand>/.claude` for that brand, never the default `~/.claude`.

## 3. Confirm the LaunchAgent is up

```bash
launchctl print gui/$(id -u)/com.rubytech.maxy-code | head -20
```

You should see `state = running`. If the state is `not running` or the command fails, inspect the plist and the supervised stdout/stderr files referenced inside it:

```bash
cat ~/Library/LaunchAgents/com.rubytech.maxy-code.plist
```

The plist points at the wrapper script the installer wrote and at log files under `$HOME/.maxy-code/logs/`. `launchctl bootstrap`'s exit code is recorded in the install log as `[create-maxy] launchd-plist=… loaded=true|false`.

## 4. Open the admin UI

The install log's final block prints the URL. For the default brand on a default install:

```
================================================================

  Open in your browser: http://<hostname>.local:<port>

================================================================
```

Open that URL in any browser. The admin UI loads, the operator account is provisioned on first visit, and the platform's chat surface is ready.

## 5. Verify reboot persistence

Reboot the Mac. After login, the LaunchAgent reattaches automatically because the plist sets `RunAtLoad=true` and `KeepAlive=true`. Re-open the admin URL — it should respond within a few seconds without you doing anything.

If the admin UI does not respond after reboot:
- Re-check `launchctl print gui/$(id -u)/com.rubytech.maxy-code` for `state = running`.
- Tail the supervised log under `$HOME/.maxy-code/logs/`.
- The wrapper script reloads `$HOME/.maxy-code/.env` before exec'ing the platform binary; if you edited that file by hand and broke a quoted value, the supervisor will respawn on a fast loop and the URL never becomes reachable.

## Uninstall

```bash
npx @rubytech/create-maxy-code --uninstall
```

This unloads the LaunchAgent (`launchctl bootout gui/$UID/com.rubytech.maxy-code`), removes the plist, removes the Homebrew formula state the installer added, and removes the persist directory `$HOME/.maxy-code/`. After it completes the brand leaves no trace.

To uninstall a non-default brand, point at its package — for example:

```bash
npx @rubytech/create-realagent-code --uninstall
```

## What this install does not do

macOS is the lightweight surface. Compared with the Pi install, the macOS path deliberately skips:

- **No cgroup / resource decoupling.** Pi installs decouple Claude Code's cgroup from systemd's session scope so a closed VNC viewer cannot reap the long-running agent. macOS uses launchd, which is already per-user and does not have the same cleanup pathology, so the work is unnecessary.
- **No VNC.** The admin UI is the surface. You drive it from a browser on the same machine or any device on the LAN; there is no display server to bootstrap.
- **No `cloudflared` tunnel by default.** Pi installs ship a tunnel because the device is typically headless and on a residential network. On a Mac the LAN URL is usually enough; if you want a public URL, install `cloudflared` separately and run `cloudflared tunnel login` from the terminal. The tunnel uses the CLI's interactive `tunnel login` flow; other Cloudflare operations (DNS, Pages, D1) use the API with a token the agent mints from an operator-provisioned master.

## Smoke checklist

The full operator-side fresh-Mac smoke is tracked separately (see `.tasks/339-macos-installer-smoke-task-297.md`). The headline pass criteria:

1. Install on a clean account with no prior Maxy footprint completes and prints an admin URL.
2. The admin UI opens at that URL and the chat surface is interactive.
3. Reboot — the URL is reachable again after login without any manual action.
4. Run `--hostname <name>` on a second install path; the URL switches to `<name>.local`.
5. Uninstall removes the LaunchAgent, the plist, and the persist directory.

If any step fails, attach `$HOME/.<brand>/logs/create-maxy-<timestamp>.log` to the report.

---
# Raspberry Pi Install
Source: https://docs.getmaxy.com/install/pi.md

# Installing Maxy Code on a Raspberry Pi

End-to-end install for a fresh Raspberry Pi 5 (16GB) on Ubuntu Server 24.04 (64-bit). Every command is copy-pasteable and uses auto-yes flags so nothing prompts interactively. The same flow works on a Pi 4 (8GB). For a Hetzner Cloud install (CAX31 ARM64 ~€13/mo), see [hetzner.md](hetzner.md) — same installer, slightly different bootstrap for the Cloudflare tunnel because there is no LAN to the operator.

The doc is brand-aware. Examples use the default brand `maxy-code`; substitute `realagent-code` (or any other brand under `maxy-code/brands/`) wherever you want a parallel install. Each brand is fully isolated — its own persist directory, its own systemd user-service, its own Neo4j port, its own VNC display, its own Cloudflare tunnel, its own `CLAUDE_CONFIG_DIR`.

> macOS install: see [macos.md](macos.md) for the laptop flow.
> Architecture notes for engineers: see [../deployment.md](../deployment.md).

## Requirements

- Raspberry Pi 5, 16GB RAM (canonical) — Pi 4 8GB works but the first install runs slower.
- Ubuntu Server 24.04 LTS, 64-bit, freshly imaged with Raspberry Pi Imager. Earlier Ubuntu / Pi OS releases are not part of the supported matrix.
- The pi has a wired or Wi-Fi route to the internet and an SSH-reachable user with sudo (the username does not matter — Rubytech images ship `admin` by default).
- A Cloudflare account whose dashboard you can sign into in a web browser. The tunnel signs in via `cloudflared tunnel login` (OAuth, in the Pi's VNC browser after install); DNS/Pages/D1 use the Cloudflare API with a token the agent mints from a master token you provision once.
- A connected monitor or a working VNC viewer for the one-time `cloudflared tunnel login` step. After that step the Pi runs headless.

For Hetzner Cloud, see [hetzner.md](hetzner.md). The apt path, systemd user-service, and Cloudflare flow are the same; the difference is that a cloud VM has no physical display and no LAN to the operator, so the noVNC browser is reached over SSH port-forwarding for the one-time Cloudflare bootstrap.

## 1. Prepare the OS

Update the package index and install Node 22 from NodeSource. Pi OS / Ubuntu archive Node is too old; the installer reads `node` from `PATH` and a 20.x binary trips the engines check.

```bash
sudo apt-get update
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

# Verify (must be 22.6 or newer)
node --version
```

Everything else the installer needs (apt deps for the VNC stack, `cloudflared`, Neo4j, Ollama, Chromium) is installed by `@rubytech/create-maxy-code` in step 2 — do not pre-install them by hand.

## 2. Run the installer

The default brand is `maxy-code`. Run as the same user that will operate the device (do not run with `sudo`; the installer escalates internally where it needs to). The `--hostname` flag is required on Pi and cloud VM — it becomes the Cloudflare-fronted hostname and the systemd unit name, and it is the hostname the LAN sees over mDNS.

```bash
npx -y @rubytech/create-maxy-code@latest --hostname <hostname>
```

Pick a `<hostname>` that is short, lowercase, and unique across your Cloudflare account (e.g. `maxy-alice`). The installer sets `HostName`, `LocalHostName`, and the Avahi `host-name` to this value, then registers a systemd user-service named `<hostname>.service` that owns the platform process.

That command:

- creates the persist directory `$HOME/.maxy-code/` (logs, config, plugin state, the `.claude/` config tree, browser profile);
- exports `CLAUDE_CONFIG_DIR=$HOME/.maxy-code/.claude` for every Claude Code invocation it spawns (default `~/.claude` is the wrong tree on a multi-brand machine);
- `apt-get install -y` for the base deps, the VNC stack (`tigervnc-standalone-server`, `python3-websockify`, `novnc`, `xdg-utils`, `chromium`, `xterm`, `xdotool`), `cloudflared`, Neo4j 5.x, and `nodejs`;
- swaps a snap-Chromium for a deb-packaged Chromium (or Google Chrome) when the Ubuntu image ships Chromium as a snap — snap-confined Chromium cannot run inside the VNC display;
- builds the platform payload bundled in the npm tarball;
- writes a systemd user-service at `~/.config/systemd/user/<hostname>.service` and enables it with `systemctl --user enable --now`;
- prints the LAN URL `http://<hostname>.local:<port>` when the supervisor reports the server is listening. The Cloudflare-fronted public URL is not provisioned at install time — step 4 below.

The full install log lands at `$HOME/.maxy-code/logs/install-<timestamp>.log`. Every phase line is prefixed `[create-maxy] phase=… brand=… platform=linux` — that is the canonical signal if you want to attach an install log to a support request.

If `~/.maxy-code/logs/install-*.log` is empty after a failed install, grep the installer's stdout for `[create-maxy] platform=`, `[create-maxy] log=`, and `[create-maxy] init-logging FAILED reason=`. The installer emits those to stdout (and stderr for the last one) before any log file write.

### Installing a second brand

To run, for example, `realagent-code` alongside the default install on the same Pi, repeat step 2 with that brand's package and a different hostname:

```bash
npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
```

The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned as a dedicated `neo4j-<realagent-hostname>` service on its own port, and the VNC display + websockify + ttyd ports all shift to the brand's reserved range. There is no shared state; `CLAUDE_CONFIG_DIR` is always `$HOME/.<brand>/.claude` for that brand, never the default `~/.claude`.

## 3. Confirm the systemd user-service is up

```bash
systemctl --user status <hostname>.service
```

You should see `Active: active (running)`. If the unit is in `failed` or `activating` state, tail the supervised journal:

```bash
journalctl --user -u <hostname>.service -n 200 --no-pager
```

The unit reads its environment from `$HOME/.maxy-code/.env`; if you edited that file by hand and broke a quoted value, the supervisor will respawn on a fast loop and the LAN URL never becomes reachable.

The installer also wires `loginctl enable-linger <user>` so the user-service survives logout. If `loginctl show-user <user> | grep Linger` does not return `Linger=yes`, re-run the installer or `sudo loginctl enable-linger <user>` by hand — without linger the service stops when you log out of the Pi.

## 4. Bootstrap the Cloudflare tunnel

The installer puts `cloudflared` on PATH but does not provision the tunnel — Cloudflare tunnel auth happens once, interactively, in a browser the operator drives. The tunnel's only auth path is `cloudflared tunnel login`, which writes a browser-issued cert to `$HOME/.maxy-code/.cloudflared/cert.pem` on success. Other Cloudflare operations (DNS, Pages, D1) use the API with a token the agent mints from an operator-provisioned master — see the `cloudflare` plugin.

Open the Pi's VNC browser at `http://<hostname>.local:<port>/vnc` (or over the LAN at whichever port the install log printed for noVNC). In the chat surface, ask the agent to run the Cloudflare setup — the [`cloudflare`](../../platform/plugins/cloudflare/PLUGIN.md) plugin's `cloudflare` skill walks `cloudflared tunnel login`, `cloudflared tunnel create`, `cloudflared tunnel route dns`, and the systemd `<hostname>-cloudflared.service` unit in order, streaming `cloudflared`'s stdout verbatim into chat. The OAuth URL the CLI prints is linkified by the PTY; the operator clicks it inside the VNC browser and authorises the cert against the right Cloudflare account.

Setup is done when, and only when, `curl -I https://<hostname>.<your-zone>` issued from outside the local network returns `HTTP/2 200`. No state file, no `tunnel run` exit code, and no "service is active" claim substitutes for the live HTTPS response.

## 5. Open the admin UI

After step 4 the public URL is your Cloudflare-fronted hostname. Open it in any browser, sign in, and the admin UI loads.

On the LAN (or pre-tunnel), the URL is `http://<hostname>.local:<port>` — the install log's final block prints both addresses:

```
================================================================

  Open in your browser: http://<hostname>.local:<port>
  Public URL (after Cloudflare setup): https://<hostname>.<your-zone>

================================================================
```

## 6. Verify reboot persistence

Reboot the Pi (`sudo reboot`). After the boot completes, the systemd user-service reattaches automatically because the unit is enabled and `loginctl enable-linger` was set. Re-open the LAN or public URL — it should respond within ten or twenty seconds without you doing anything.

If the admin UI does not respond after reboot:

- `systemctl --user status <hostname>.service` — confirm `active (running)`.
- `journalctl --user -u <hostname>.service -n 200 --no-pager` — tail the supervisor log.
- `loginctl show-user <user> | grep Linger` — confirm `Linger=yes`. Without it the user-service does not start until you SSH in.
- `systemctl --user status <hostname>-cloudflared.service` — confirm the tunnel is up. The platform unit can be healthy while the tunnel is not, in which case the LAN URL works and the public URL does not.

## Uninstall

```bash
npx -y @rubytech/create-maxy-code@latest --uninstall
```

This stops and disables the systemd user-service, removes the unit file, removes the Avahi service file, removes the brand's `sysctl.d` QUIC-tuning file, and removes the persist directory `$HOME/.maxy-code/`. Shared apt packages (Node, Neo4j, Chromium, the VNC stack, `cloudflared`) stay on the system — the operator removes them with `sudo apt-get purge` if they want a clean slate.

To uninstall a non-default brand, point at its package — for example:

```bash
npx -y @rubytech/create-realagent-code@latest --uninstall
```

## What this install does not do

- **No SCP / rsync.** The Pi is reached over npm only. Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
- **Tunnel auth is OAuth; the API is permitted for the rest.** The tunnel's only auth path is `cloudflared tunnel login` in the Pi's VNC browser. DNS, Pages, and D1 use the Cloudflare API with a short-lived narrow token the agent mints from an operator-provisioned master token (see the `cloudflare` plugin); tokens are never written to a project tree or echoed.
- **No shared state across brands.** Two brands on one Pi each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory. They do not share DNS, ports, or filesystem state.

## Smoke checklist

Fresh-Pi smoke pass criteria:

1. Install on a clean Ubuntu Server 24.04 image with no prior Maxy footprint completes, prints a LAN URL, and the systemd user-service is `active (running)`.
2. The LAN URL `http://<hostname>.local:<port>` opens the admin UI and the chat surface is interactive.
3. Cloudflare setup driven by the `cloudflare` plugin's `cloudflare` skill ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from outside the LAN.
4. Reboot — both URLs are reachable again after boot without any manual action.
5. Install a second brand with a different `--hostname`; both brands' admin UIs are reachable on their own ports / public URLs and neither has touched the other's state.
6. Uninstall removes the systemd unit, the Avahi service file, and the persist directory.

If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.

---
# Hetzner Cloud Install
Source: https://docs.getmaxy.com/install/hetzner.md

# Installing Maxy Code on a Hetzner Cloud server

End-to-end install for a fresh Hetzner Cloud server on the **CAX31** tier (8 vCPU Ampere Altra ARM64, 16 GB RAM, 160 GB NVMe, ~€13/mo). CAX is the right tier because it is ARM64, identical chip family to the Raspberry Pi 5, so every binary built by the installer compiles the same way it does on the Pi. Every command is copy-pasteable and uses auto-yes flags so nothing prompts interactively.

The doc is brand-aware. Examples use the default brand `maxy-code`; substitute `realagent-code` (or any other brand under `maxy-code/brands/`) wherever you want a parallel install. Each brand is fully isolated — its own persist directory, its own systemd user-service, its own Neo4j port, its own VNC display, its own Cloudflare tunnel, its own `CLAUDE_CONFIG_DIR`.

> Pi install: see [pi.md](pi.md). macOS install: see [macos.md](macos.md). Architecture notes for engineers: see [../deployment.md](../deployment.md).

> **Data sovereignty note.** Installing on Hetzner moves the operator's graph and conversations from a device they own onto a rented server. For internal use or for operators who explicitly prefer cloud hosting, fine. As the default for customers, this cuts against the inverted-SaaS positioning — surface the trade-off before recommending it.

## Server spec

| Field | Value | Why |
|---|---|---|
| Tier | **CAX31** | 8 vCPU, 16 GB RAM, 160 GB NVMe, ~€13/mo. RAM matches the Pi 16GB; ARM64 keeps binary compatibility. CAX11/21 are under-spec for the platform's Neo4j + Chromium + Ollama footprint. |
| Image | Ubuntu 24.04 LTS (arm64) | Same image family supported by the Pi install. Earlier Ubuntu / non-LTS images are not part of the supported matrix. |
| Location | Nearest to the operator (Falkenstein, Nuremberg, Helsinki, Hillsboro, Singapore) | Latency to the admin browser; choice does not affect the install. |
| Network | IPv4 + IPv6 | The Cloudflare tunnel terminates all public traffic; the server's own IPv4 is not exposed to operators after step 4. |
| Firewall | SSH (22) inbound only | Every other inbound surface is fronted by the Cloudflare tunnel, which dials *out* to Cloudflare. |
| SSH key | Added at provision time | Hetzner does not enable password SSH on the default Ubuntu image when an SSH key is attached. |

A CAX11 or CAX21 cannot run the platform. The Pi 16GB is the floor; CAX31 is the like-for-like Hetzner equivalent.

## 1. Provision the server

In the [Hetzner Cloud console](https://console.hetzner.cloud):

1. Create a project (or use an existing one).
2. Add server → **Location**: nearest region → **Image**: Ubuntu 24.04 → **Type**: Arm64 → **CAX31**.
3. Add your SSH key under **SSH keys** (or paste it inline). Skip the cloud-init / user-data field.
4. Name the server (e.g. `maxy-alice`) and create it.

When the server reaches `Running`, copy its public IPv4. SSH in as `root`:

```bash
ssh root@<ipv4>
```

## 2. Prepare the OS

Update the package index and install Node 22 from NodeSource. The Ubuntu archive Node is too old; the installer reads `node` from `PATH` and a 20.x binary trips the engines check.

```bash
apt-get update
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs

# Verify (must be 22.6 or newer)
node --version
```

Create a non-root user that will own the install and the systemd user-service. Running the platform as `root` is supported but not recommended; the rest of this doc assumes a user named `admin` (matching the Pi default).

```bash
adduser --disabled-password --gecos "" admin
usermod -aG sudo admin
echo "admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/admin
chmod 440 /etc/sudoers.d/admin
mkdir -p /home/admin/.ssh
cp ~/.ssh/authorized_keys /home/admin/.ssh/authorized_keys
chown -R admin:admin /home/admin/.ssh
chmod 700 /home/admin/.ssh
chmod 600 /home/admin/.ssh/authorized_keys

# From now on, SSH as admin, not root
ssh admin@<ipv4>
```

Everything else the installer needs (apt deps for the VNC stack, `cloudflared`, Neo4j, Ollama, Chromium) is installed by `@rubytech/create-maxy-code` in step 3.

## 3. Run the installer

The default brand is `maxy-code`. Run as the `admin` user (do not use `sudo`; the installer escalates internally where it needs to). The `--hostname` flag is required on a cloud VM — it becomes the Cloudflare-fronted hostname and the systemd unit name.

```bash
npx -y @rubytech/create-maxy-code@latest --hostname <hostname>
```

Pick a `<hostname>` that is short, lowercase, and unique across your Cloudflare account (e.g. `maxy-alice`). The installer:

- creates the persist directory `$HOME/.maxy-code/` (logs, config, plugin state, `.claude/` config tree, browser profile);
- exports `CLAUDE_CONFIG_DIR=$HOME/.maxy-code/.claude` for every Claude Code invocation;
- `apt-get install -y` for base deps, the VNC stack (`tigervnc-standalone-server`, `python3-websockify`, `novnc`, `xdg-utils`, `chromium`, `xterm`, `xdotool`), `cloudflared`, Neo4j 5.x, and `nodejs`;
- swaps a snap-Chromium for a deb-packaged Chromium when the Ubuntu image ships Chromium as a snap;
- builds the platform payload bundled in the npm tarball;
- writes a systemd user-service at `~/.config/systemd/user/<hostname>.service` and enables it with `systemctl --user enable --now`;
- prints the loopback URL `http://localhost:<port>` when the supervisor reports the server is listening.

The full install log lands at `$HOME/.maxy-code/logs/install-<timestamp>.log`.

### Installing a second brand

Repeat step 3 with the other brand's package and a different hostname:

```bash
npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
```

The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned on its own port, and the VNC display + websockify + ttyd ports shift to the brand's reserved range.

## 4. Reach the dashboard and VNC browser over SSH port-forwarding

On the Pi both the admin UI and the noVNC page are reachable over the LAN. On Hetzner there is no LAN to the operator, so both surfaces are forwarded over SSH until the Cloudflare tunnel exists.

All `ssh -L` commands in this step are run on **your local machine** — the machine you SSH from, not on the Hetzner server.

Both the dashboard and the VNC browser can be forwarded in a single SSH session using two `-L` flags. On your local machine, open one terminal and run:

```bash
ssh -L 19200:localhost:19200 -L 6080:localhost:6080 admin@<ipv4>   # maxy-code
# or
ssh -L 19200:localhost:19200 -L 6081:localhost:6081 admin@<ipv4>   # realagent-code
```

While that session is open:
- `http://localhost:19200` — dashboard
- `http://localhost:6080/vnc.html` — VNC browser (Claude's OAuth and Cloudflare setup run here)

The server-side ports are fixed by brand (`19200` dashboard, `6080`/`6081` VNC). When managing multiple servers simultaneously, vary only the left-hand (local) ports:

```bash
# One terminal per server, on your local machine
ssh -L 19200:localhost:19200 -L 6080:localhost:6080 admin@server1
ssh -L 19201:localhost:19200 -L 6081:localhost:6080 admin@server2
ssh -L 19202:localhost:19200 -L 6082:localhost:6080 admin@server3
```

After the Cloudflare tunnel is provisioned, close the SSH session — every surface is reachable at the public hostname.

## 5. Bootstrap the Cloudflare tunnel

The installer puts `cloudflared` on PATH but does not provision the tunnel — Cloudflare tunnel auth happens once, interactively, in the noVNC browser the operator drives over the SSH forward from step 4. The tunnel's only auth path is `cloudflared tunnel login`, which writes a browser-issued cert to `$HOME/.maxy-code/.cloudflared/cert.pem` on success. Other Cloudflare operations (DNS, Pages, D1) use the API with a token the agent mints from an operator-provisioned master — see the `cloudflare` plugin.

In the noVNC browser session, open the admin UI at `http://localhost:<port>`. In chat, ask the agent to run the Cloudflare setup — the [`cloudflare`](../../platform/plugins/cloudflare/PLUGIN.md) plugin's `cloudflare` skill walks `cloudflared tunnel login`, `cloudflared tunnel create`, `cloudflared tunnel route dns`, and the systemd `<hostname>-cloudflared.service` unit in order, streaming `cloudflared`'s stdout verbatim into chat. The OAuth URL the CLI prints is linkified by the PTY; the operator clicks it inside the noVNC browser and authorises the cert against the right Cloudflare account.

Setup is done when, and only when, `curl -I https://<hostname>.<your-zone>` issued from the operator's laptop returns `HTTP/2 200`. No state file, no `tunnel run` exit code, and no "service is active" claim substitutes for the live HTTPS response.

The SSH port-forward from step 4 can be closed after this point.

## 6. Open the admin UI

After step 5 the public URL is your Cloudflare-fronted hostname. Open it in any browser (laptop, phone, tablet), sign in, and the admin UI loads.

The Hetzner server's IPv4 is not advertised anywhere; the only public surface is the Cloudflare hostname. If the operator's laptop is offline, the loopback URL inside an SSH session (`http://localhost:<port>` over `ssh -L`) still works.

## 7. Verify reboot persistence

Reboot the server (`sudo reboot`). After it comes back up, SSH back in and confirm:

```bash
systemctl --user status <hostname>.service
systemctl --user status <hostname>-cloudflared.service
```

Both should be `Active: active (running)` within ten or twenty seconds of boot. `loginctl show-user admin | grep Linger` must report `Linger=yes` — without it the user-service does not start until you SSH in. The installer sets linger; if it is missing, run `sudo loginctl enable-linger admin`.

Open the public URL from outside the server's network and confirm the admin UI is reachable without any manual action.

## Uninstall

```bash
npx -y @rubytech/create-maxy-code@latest --uninstall
```

This stops and disables the systemd user-service, removes the unit file, removes the brand's `sysctl.d` QUIC-tuning file, and removes the persist directory `$HOME/.maxy-code/`. Shared apt packages (Node, Neo4j, Chromium, the VNC stack, `cloudflared`) stay on the system. To wipe the box completely, destroy the Hetzner server from the cloud console.

To uninstall a non-default brand, point at its package:

```bash
npx -y @rubytech/create-realagent-code@latest --uninstall
```

## What this install does not do

- **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
- **Tunnel auth is OAuth; the API is permitted for the rest.** The tunnel's only auth path is `cloudflared tunnel login` in the noVNC browser over SSH forward. DNS, Pages, and D1 use the Cloudflare API with a short-lived narrow token the agent mints from an operator-provisioned master token (see the `cloudflare` plugin).
- **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
- **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel.

## Smoke checklist

Fresh-Hetzner smoke pass criteria:

1. Provision a CAX31 with Ubuntu 24.04 arm64 and an SSH key; SSH in as `root`, create `admin`, switch.
2. Install completes on the clean image, prints a loopback URL, and the systemd user-service is `active (running)`.
3. The noVNC page reached over `ssh -L 8080:localhost:<novnc-port>` displays the admin UI.
4. Cloudflare setup driven by the `cloudflare` plugin's `cloudflare` skill ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
5. Reboot the server; both `<hostname>.service` and `<hostname>-cloudflared.service` come back up; the public URL is reachable again without any manual action.
6. Install a second brand with a different `--hostname`; both brands' admin UIs are reachable on their own public hostnames and neither has touched the other's state.
7. Uninstall removes the systemd unit and the persist directory.

If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.

---
# Cloudflare Tunnel
Source: https://docs.getmaxy.com/cloudflare.md

# Cloudflare Tunnel — the dashboard is the source of truth

Each installation has its own Cloudflare account. The **tunnel** sign-in is OAuth: the agent invokes `cloudflared tunnel login` via Bash; the Cloudflare Authorize URL streams into the admin chat PTY and the native terminal renders it as a clickable link. Click it, authorise in your own browser, and `cloudflared` writes `cert.pem` to the brand's config directory. For **everything else** (DNS, Pages, D1, Access) the agent uses the Cloudflare API, authenticated by a short-lived narrow token it mints from a master token you provision once in the dashboard (an advanced step the agent never automates). Some account-side jobs — adding a domain, switching accounts — are still easiest in your browser, and the agent relays those click-paths; the rest it can do directly via the API.

## Identity model

| Concept | Source |
|------|--------|
| **Product identity** (Maxy vs Real Agent) | `brand.json` (`productName`, `configDir`) — known at install. |
| **Cloudflare account identity** | `cert.pem` from OAuth. One account per brand per device. |
| **Domain scope** (which zones the operator can route) | The operator picks the zone in the dashboard during OAuth or names it in chat; the agent can also enumerate zones via the API with a minted read-scoped token. |
| **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `alias-domains.json`. |

Tunnel auth on the operator-owned path (Mode A) is the OAuth cert (`cert.pem`); API operations use a narrow token the agent mints from your master token. To switch Cloudflare accounts, the agent runs the reset flow from `plugins/cloudflare/references/reset-guide.md` (deletes the cert and every tunnel on the current account), then the manual-setup flow again — `cloudflared tunnel login` picks a fresh account when you sign in.

## Setup flow

Ask the agent to set up Cloudflare. The agent confirms the domain is already on your Cloudflare account (if not, it quotes the dashboard click-path — see below) and collects the inputs in plain chat:

- **Admin address** — the hostname that will serve the admin chat (e.g. `admin.yourdomain.com`).
- **Public address** — optional hostname for the public agent (e.g. `public.yourdomain.com` or `chat.yourdomain.com`).
- **Proxy apex** — optional bare-domain hostname (e.g. `yourdomain.com`) that should also serve the public agent.
- **Admin password** — the password used to gate remote access to the admin surface.

The agent then sets the admin password via `curl -X POST http://127.0.0.1:${PORT}/api/remote-auth/set-password` (same endpoint the local onboarding form uses), and works through `plugins/cloudflare/references/manual-setup.md` Steps 1–7 directly via the Bash tool. `cloudflared`'s stdout streams into the PTY verbatim. The OAuth URL is linkified by the terminal; click it in your own browser to authorise. After the tunnel is up, the agent appends each non-`public.*` public or apex hostname to `~/{configDir}/alias-domains.json` so `isPublicHost()` classifies it as public, and starts the brand's cloudflared user service.

If any step's `cloudflared` invocation exits non-zero, the agent names the literal exit code, surfaces the stderr verbatim, and cites `reset-guide.md` for the next action — no retry under a different flag, no Playwright-driven dashboard inspection.

The setup-done claim only fires after the agent runs `curl -I https://<admin-hostname>` from outside the local network and the response shows a `200` line. That HTTP response is the only success terminal.

## Getting a domain on Cloudflare

The tunnel needs a domain on the Cloudflare account the device will sign into. Two paths, both in your browser:

**Option A: Buy a new domain through Cloudflare.** Navigate to cloudflare.com → Domains and buy one. Cloudflare sets everything up.

**Option B: Add an existing domain.** In the dashboard: Websites → Add a site. Cloudflare imports the existing DNS records; review them to confirm your website and email entries are preserved. Cloudflare gives you two nameservers; replace the registrar's nameservers with those. Propagation is usually minutes (up to 24 hours); the zone shows **Active** when ready.

Existing website traffic continues to work during and after the switch. Only DNS resolution changes owners.

## Reset / account switch

Ask the agent to reset Cloudflare. The agent executes the reset flow from `plugins/cloudflare/references/reset-guide.md`:

- Deletes every tunnel on the brand's current Cloudflare account (via the bound cert).
- Wipes the brand's `${CFG_DIR}`.
- Stops the brand's cloudflared user service.

The agent does **not** stop token-mode connector processes or delete stray misrouted CNAMEs in the dashboard. If any of those apply, the agent guides you through the manual cleanup — `pkill -f 'cloudflared.*tunnel run --token'` on the device, or deleting the stray CNAME in the dashboard.

After reset, run setup again. The fresh `cloudflared tunnel login` will pick whichever Cloudflare account you sign into.

## Manual runbook

The step-by-step runbook at `plugins/cloudflare/references/manual-setup.md` is the contract the agent follows. It is also what an operator runs by hand when needed — every numbered step is an isolated `cloudflared` command block with success conditions and troubleshooting.

## Dashboard operations the CLI cannot do

The CLI cannot add a domain, switch accounts, edit an apex CNAME, or delete stray records. `plugins/cloudflare/references/dashboard-guide.md` has one numbered click-path per operation. The agent quotes the relevant steps verbatim when you need to do one of these things.

## Troubleshooting

### Tunnel won't start

Ask the agent to check. The agent reads `systemctl --user status ${BRAND}-cloudflared.service` and the cloudflared log under `~/{configDir}/cloudflared/`. Common states:

- **No cloudflared process running** — the cloudflared service exited or never started. The agent runs the manual-setup flow to re-issue tunnel creation.
- **`tunnel not found`** — the UUID in `config.yml` does not match any tunnel on the currently-bound account. Usually follows an account switch that didn't reset local state. The agent runs the reset flow and then a fresh setup.

### URL returns 530

DNS propagation or account mismatch. Wait 30–60 seconds and retry first. If the 530 persists:

- The domain may be on a Cloudflare account different from the one `cert.pem` is bound to — the agent re-runs the manual setup steps to re-validate.
- The UDP buffer for QUIC may be undersized on this device — check the cloudflared log for `failed to sufficiently increase receive buffer size`.

### URL returns connection refused

The tunnel is live but nothing is listening on the platform port. Start the platform service: `systemctl --user start ${BRAND}.service`.

### Admin hostname serves the public agent

`admin.yourdomain` is being misclassified as public. The platform UI treats a host as public when either (a) the hostname starts with `public.`, or (b) the hostname appears in `${CFG_DIR}/alias-domains.json`. Older install flows wrote every routed hostname into `alias-domains.json`; the pollution survives across reinstalls.

The agent reads `alias-domains.json`, removes the offending `admin.*` entry, and the platform UI hot-reloads — no restart needed. See `plugins/cloudflare/references/reset-guide.md` § "Remove a rogue entry from alias-domains.json" for the exact `jq` command.

### DNS not resolving

The most common cause is wrong nameservers on the domain. The domain must use Cloudflare's nameservers, not the registrar's defaults. In the dashboard: Websites → your domain → status must say **Active**, not **Pending**. If Pending, follow the dashboard's nameserver instructions and wait for propagation.

### Remote login issues

- 5 failed login attempts → 15-minute lockout — wait for expiry.
- The remote password is set during Cloudflare Tunnel onboarding — the agent asks for one in chat and stores it deterministically. The browser form at `/__remote-auth/setup` remains available for resets on the local network.

## What the agent does and does not do

**Does:** invokes `cloudflared` directly via Bash, following `plugins/cloudflare/references/manual-setup.md` step by step; quotes click-paths from the reference files verbatim; verifies external reachability with `curl -I` and surfaces the response.

**Does not:** drive the Cloudflare dashboard via Playwright, browser-automate master-token creation, synthesise alternative `cloudflared` flag sequences not in the runbook, write or echo any API token, write or edit `cert.pem` / `config.yml` directly outside the runbook's instructions.

When a command fails, the agent reports the failure and cites the relevant recovery step. It does not improvise.

---
# Access Control
Source: https://docs.getmaxy.com/access-control.md

# Access Control

## What It Is

Access control determines who can chat with your public agent. By default, anyone with your public URL can start a conversation. You can restrict this so only invited people have access, and so the agent remembers each invitee separately without leaking what one visitor said to the others.

## Access Modes

Each public agent has one of two access modes:

| Mode | Who can chat | What the agent remembers |
|------|--------------|--------------------------|
| Open (default) | Anyone with the URL. No login required. | Public-scope knowledge only. Nothing per-visitor. |
| Gated | Invitation only. Visitors authenticate by clicking a fresh emailed link each session. | A separate per-visitor memory slice. Visitor A and visitor B never see each other's memory. |

## How to Set It Up

Tell Maxy: "Set my public agent to gated access" or "Make the coaching agent invitation-only."

Maxy flips the agent's access mode. The next visitor to your public URL sees a sign-in screen instead of the chat.

## Inviting Visitors

Tell Maxy: "Invite sarah@client.co to the coaching agent."

Maxy creates an invitation and emails the visitor a magic link. At creation time the invitation is stamped with a one-off `sliceToken` — that token is what binds every per-visitor memory write to this specific invitation for the life of the invite.

Only email invitations are supported. Phone, OTP, and password flows are not part of the current build.

## What Visitors Experience

- **First visit (invited):** The visitor opens the email and clicks the magic link. They land on your public URL, the cookie is set, and the chat opens. No password to remember.
- **Return visits / lost the email:** The visitor visits your URL directly, types the email they were invited on, and clicks "Send me a link." A fresh magic link arrives within seconds. The new link replaces the previous one — old links go inert.
- **Browser close:** The cookie is session-only. Closing the tab signs the visitor out. They click the latest magic link, or request a new one, to come back.
- **Revoked or expired:** Their next request is bounced back to the sign-in screen. They cannot get past it until you re-invite them.

## Per-Visitor Memory

Every gated visitor has their own ringfenced memory slice. When the agent talks to visitor A, it sees everything tagged with A's slice plus the agent's general public-scope knowledge. It cannot see visitor B's slice, and it cannot see your admin-scope notes. The same gate applies in reverse — nothing the visitor says leaks into your admin graph by accident.

The slice is populated automatically at the end of each conversation. When a visitor's chat session is reaped (idle timeout, or the visitor closes the tab), a background reviewer reads the transcript and writes anything worth saving into the visitor's slice. The visitor sees the new context the next time they return.

You can read what's in a visitor's slice via the cypher tools in conversation — "show me what we know about Sarah" — but the slice writes themselves happen autonomously without your involvement.

## Managing Access

All access management is done through conversation with Maxy:

- "Who has access to my coaching agent?" — lists active visitors and their `sliceToken`.
- "Revoke Sarah's access" — flips her grant to revoked AND immediately drops her active session, so she cannot continue talking on a live cookie. Her slice's historical memory stays in the graph; you can purge it separately if needed.
- "Extend Tom's access by 30 days" — pushes the expiry date forward. Slice unchanged.
- "Resend Sarah's invitation" — generates a fresh magic link and emails it. The slice stays the same, so her existing memory carries over.

Revoking + re-inviting the same person on a new invitation produces a fresh slice — the old slice's memory does not transfer. This is by design: a fresh invitation is a fresh relationship.

## Visitor Identity

When a visitor is authenticated, your public agent knows their name and contact details — it reads them from the visitor's `:Person` node, which is linked to their grant. It can personalise responses ("Welcome back, Sarah") without needing to ask.

## Action Approval

External-facing actions — sending emails, WhatsApp messages, Telegram messages, and erasing contacts — require your approval before Maxy executes them. This is human oversight as required by the EU AI Act.

When Maxy needs to send a message or perform a consequential action, it drafts the action and queues it for your review. You'll see it in your next chat turn:

- "Approve it" — Maxy executes the action immediately
- "Reject it" — the action is cancelled
- "Change the subject to X" — Maxy modifies the action and executes the edited version

Internal operations (creating tasks, updating contacts, searching memory) execute automatically without approval.

### Changing the Policy

Tell Maxy to change which actions require approval:

- "Auto-send follow-up emails from now on" — emails execute without approval
- "Require approval for all WhatsApp messages" — restores the default gating
- "What actions currently require my approval?" — lists the current policy

Changes are per-account and take effect immediately.

## Filesystem Access (SMB Share)

Brand isolation extends to the device filesystem. Every Maxy install provisions an SMB share scoped to that brand's install folder, credentialled by the brand's install owner and the Maxy PIN. A device that hosts more than one brand carries one share per brand; tearing one brand down never exposes another brand's files. See [Samba Share](./samba.md) for the credential model, per-OS mount syntax, and peer-brand lifecycle.

---
# Settings
Source: https://docs.getmaxy.com/settings.md

# Settings

## Output Style

Controls how Maxy communicates with you.

| Style | Behaviour |
|-------|-----------|
| `default` | Concise, direct responses — gets to the point |
| `explanatory` | More detailed responses with educational context — explains reasoning and trade-offs |

**Changing output style:** Tell Maxy "Switch to explanatory mode" or "Use default output style."

Changes take effect on the next session. The current session continues with the existing style.

## Effort Level

Controls how much work Maxy puts into each task — specifically, how many steps it takes before stopping and checking with you.

| Level | Max turns | Use when |
|-------|-----------|----------|
| `low` | 5 | Quick questions, simple lookups |
| `medium` | 10 | Standard tasks — most daily use |
| `high` | 20 | Complex multi-step tasks |
| `auto` | 20 | Let Maxy decide (same ceiling as high) |
| `max` | 40 | Long autonomous workflows |

**Changing effort level:** Tell Maxy "Set effort to high" or "Use low effort mode."

Changes take effect on the next session.

## Thinking View

Controls how Maxy's thinking process is displayed in the chat.

| Mode | Behaviour |
|------|-----------|
| `default` | Thinking steps shown expanded, tool use collapsed |
| `expanded` | Everything shown expanded — thinking, tool use, and results |
| `collapsed` | Everything collapsed — compact view, expand on tap |

**Changing thinking view:** Tell Maxy "Show thinking by default", "Show everything expanded", or "Hide thinking."

Changes take effect on the next session.

## Viewing Current Settings

Ask Maxy: "What are my current settings?" or "What output style am I using?"

## Default Agent

Controls which public agent serves the root URL (`/`). Visitors who go to your public site without specifying an agent slug see this agent.

**Changing the default agent:** Tell Maxy "Make sales the default agent" or "Set the default to support."

The change takes effect on the next page load. The previous default agent remains accessible at its `/{slug}` URL.

## Account Preferences

You can ask Maxy to show or change any of the following:

- Default agent (which public agent serves the root URL)
- Admin model (which Claude model powers the admin agent)
- Public model (which Claude model powers the public agent)
- Output style
- Effort level
- Context mode
- Enabled plugins

Tell Maxy what you want to change and it handles the rest.

## PIN

Your admin PIN is set during initial setup. To change it, ask Maxy: "Change my admin PIN."

Maxy will ask for your current PIN to verify, then set the new one.

## Adding admins

To add another admin to your account, tell Maxy: "Add {name} as an admin with PIN {pin}." Maxy creates the device-level user entry (`users.json`), the account-level role entry (`account.json` admins[]), and the graph identity (Neo4j AdminUser node) — the three stores stay in lockstep. If any leg fails, Maxy returns an error naming exactly which store is dirty and what was already written; the admin record is partial and may need manual reconciliation. PINs are unique across all users on the device — a new admin needs a PIN no one else on the device is using.

If you ask Maxy to add an admin with a specific PIN and it returns a tier-cap or PIN-collision error, repeat the request with the same PIN every time you retry — otherwise Maxy auto-generates a different 4-digit PIN, silently substituting what you asked for.

---
# Contacts
Source: https://docs.getmaxy.com/contacts-guide.md

# Contacts Guide

## What a Contact Is

A contact is a Person node in Maxy's memory graph. Each person has a first name and at least one identifier — email address, phone number, or both. Optional fields include last name and job title. Contacts are linked to conversations, other people, and business context.

## Adding a Contact

Tell Maxy naturally:

- "Add John Smith to my contacts — he's a potential client I met at the conference"
- "Create a contact for sarah@acme.com, her name is Sarah Chen, she's the head of procurement at Acme"
- "Add Hazel to contacts, phone +27747309676, she's a virtual assistant"

Maxy will extract the details and confirm the record before saving.

Required: first name and at least one of email or phone number. Everything else is optional but useful.

## Looking Up a Contact

Ask naturally:

- "What do you know about John Smith?"
- "Look up Sarah Chen"
- "Find the contact from Acme procurement"
- "Look up +27747309676"

Maxy searches by name, email, phone number, or any detail you provide.

## Updating a Contact

Tell Maxy what changed:

- "Update John Smith's email to john@newcompany.com"
- "Add a note to Sarah Chen's record: prefers evening calls"
- "John Smith is now at Horizon Capital, not Acme"

## Listing Contacts

- "List all my contacts"
- "Show me everyone from Acme"
- "Who are my contacts in fintech?"

## Deleting a Contact

To remove a single contact from the graph:

- "Delete Dan from my contacts"
- "Remove the duplicate contact for Sarah Chen"
- "Delete the contact with email dan@example.com"

Maxy will confirm which Person record matches, then remove the Person node and its direct relationships (e.g. links to conversations, other people) using a graph detach-delete. The contact is gone after confirmation — this cannot be undone.

This is different from GDPR erasure (`contact-erase`). Deleting a contact removes the Person node from the graph only. GDPR erasure cascades across all data stores — access credentials, conversations, messages, and emails — to satisfy an Article 17 right-to-erasure request. Use "delete" for routine contact cleanup; use "erase all data" when fulfilling a data subject's erasure request.

## Exporting Contact Data (GDPR Subject Access)

When a person requests a copy of all data held about them, ask Maxy:

- "Export all data we hold on john@example.com"
- "Show me everything we know about +447700900123"

Maxy gathers the Person record, access credentials, conversation history, and emails into a single structured document. The output is self-contained — it can be handed directly to the data subject to satisfy an Article 15 request.

## Erasing Contact Data (GDPR Right to Erasure)

When a person requests deletion of all their data, ask Maxy:

- "Delete all data we hold on john@example.com"
- "Erase everything for Sarah Chen"

Maxy first shows a preview of what would be deleted (counts per data type). Confirm the deletion to proceed. The erasure cascade covers:

- The Person record itself
- All access credentials (AccessGrant nodes)
- Conversations and messages attributed to the contact
- Emails sent to or from the contact's email address

The deletion is permanent and irreversible. A receipt is returned listing exactly what was removed.

Note: server logs may contain residual references to the contact's identifiers. Manual log review is recommended for complete erasure.

## Stored Fields

| Field | Description |
|-------|-------------|
| `givenName` | First name (required) |
| `familyName` | Last name (optional) |
| `email` | Email address (identifier — at least one of email or telephone required; used to deduplicate) |
| `telephone` | Phone number (identifier — at least one of email or telephone required; used to deduplicate) |
| `jobTitle` | Job title or role |
| `source` | Where this contact came from (e.g. "public.maxy.bot", "telegram", "manual") |
| `status` | Contact status (e.g. "active", "prospect", "booked") |
| `createdOn` | When the record was created |

---
# Memory
Source: https://docs.getmaxy.com/memory-guide.md

# Memory Guide

## Brain-first lookup

The graph is the brain, and every turn that needs to know something runs the same five-step loop in order: (1) classify the question (entity, temporal, event, general, or none — the inbound gateway emits this as `retrievalClass`), (2) read the graph with `memory-search` (and `profile-read` when the question is about the operator) as the first tool call of the turn, (3) walk one hop to hydrate a partial hit before calling it a miss, (4) call an external tool only when steps 2–3 confirmed the graph has nothing useful, and (5) write the external evidence back via `database-operator`. The loop is what makes the next turn smarter; an external call whose result is never persisted is a leak in the brain. `retrievalClass = none` (greetings, meta-instructions) is the only exception. Operator-facing doctrine lives in [`.docs/brain-first.md`](../../../.docs/brain-first.md).

## How Memory Works

Maxy maintains a graph of everything you've told it. Contacts, conversations, preferences, relationships, business context — all stored as connected nodes in a local Neo4j database on your Raspberry Pi.

When you ask Maxy about something, it searches this graph first. It retrieves relevant context before responding, which is why Maxy can pick up where you left off even across separate sessions.

The graph lives entirely on your hardware. Nothing is sent to the cloud.

## What Gets Remembered

Maxy stores:

- **Contacts** — people, companies, relationships between them
- **Conversations** — key decisions, commitments, follow-ups mentioned in chat
- **Preferences** — things you've told Maxy about how you like to work
- **Context** — project status, ongoing threads, background you've shared

Maxy remembers details you mention naturally: "I'm meeting with Sarah on Thursday" creates a memory that Thursday has a meeting with Sarah.

## Telling Maxy to Remember Something

Just say it naturally:

- "Remember that I prefer morning calls"
- "Note that the Johnson account is on hold until March"
- "My wife's name is Emma, keep that in mind"

Maxy will confirm and store it.

## How Maxy learns how you work

Maxy also learns how you work without you having to teach it deliberately. Six broad areas cover the way most operators run a business — communication, scheduling, decisions, workflow, content, and interaction. Inside each area sits a small set of concrete fields (Maxy tracks around 28 in total) such as your preferred channel, quiet hours, workday start time, risk tolerance, content tonality, or address form. Maxy tracks which of these specific fields you have spoken into and which are still empty. While any are empty, it folds one organic question per turn into the conversation aimed at the next gap — never a list, never a form, never the same question twice. If you tell Maxy a field doesn't apply to you ("I work weekends, weekend availability isn't a thing for me"), it marks that field as covered and never re-asks. Once every field is either set or marked not-applicable, the proactive questions stop and Maxy answers what you ask without volunteering more. This is why session 300 should feel sharper than session 3: the longer you work together, the less Maxy needs to ask.

## Telling Maxy to Forget Something

Be direct:

- "Forget everything about the Johnson account"
- "Remove Sarah's contact record"
- "Clear what you know about my pricing preferences"
- "Delete that pricing guide I uploaded"

Maxy will confirm before deleting anything significant. Documents are soft-deleted first (excluded from search but recoverable for 7 days). Say "permanently delete" to remove immediately.

## Managing Documents

### Listing files

Ask: "What files do I have stored?" or "List my attachments"

Maxy shows all uploaded files with their ingestion status — whether they've been processed into the knowledge graph.

When you upload something for ingestion, Maxy emits a one-line size estimate before it starts: short documents (<5K chars) classify in ~10s; mid-size (10K–20K chars) take ~45–90s; very large (>20K) up to ~3 minutes. If the classifier exceeds its 3-minute ceiling Maxy aborts loudly with a "Classifier unavailable — timeout" blocker and writes nothing — you can re-upload or split the document.

### Reading files

Ask: "Show me what's in the pricing guide" or "Read the quarterly report"

Maxy returns the full content of text and markdown files, extracted text from PDFs, and metadata for images.

### Editing files

Ask: "Update the pricing in that document" or "Change the introduction paragraph"

Maxy reads the file, makes the edit, and prepares it for re-ingestion into the knowledge graph. Only text and markdown files can be edited — PDFs and images cannot.

### Renaming files

Ask: "Rename that file to quarterly-report-q1.pdf"

Maxy updates the filename in both the stored metadata and the knowledge graph.

### Deleting documents

Ask: "Delete the old pricing guide"

By default, documents are soft-deleted — they stop appearing in search results but remain recoverable for 7 days. To permanently delete immediately, say "permanently delete" or "force delete".

## Searching Memory

Ask naturally:

- "What do you know about Tom Henderson?"
- "What did I last discuss about the Acme proposal?"
- "Who have I met from the fintech conference?"

## Thinking tools

Three slash commands that apply analysis to what's already in your graph:

**`/challenge <claim>`** — stress-tests an assertion. Maxy searches your graph for nodes that contradict or qualify the claim — nodes that assert the opposite, name exceptions, or add significant caveats — and presents the strongest counter-case it finds. If nothing in your graph challenges the claim, it says so rather than inventing one. Results cite node IDs and relevance scores so you can inspect the sources directly.

**`/connect <topic-A> <topic-B>`** — finds the bridge. Maxy searches both topics, collects their immediate graph neighborhoods, and looks for nodes they share. If a direct bridge exists it names it in one sentence. If not, it surfaces the closest approach — the two nodes that are semantically nearest across the two sides — and proposes the connection you could draw.

**`/emerge`** — names the unnamed clusters. Maxy retrieves your KnowledgeDocument and Section nodes that are not yet connected to a Concept node, groups them by shared theme, and proposes a Concept name for each cluster. You approve or skip each proposal one at a time; nothing is written without your confirmation. Clusters of fewer than three nodes are listed at the end as "too small to cluster."

## Listing and counting

Maxy answers relational questions — "list all my people", "how many tasks do I have", "find the person with email X", "show me the 20 most recently created nodes" — via direct read-only Cypher against your Neo4j. This is faster and more precise than semantic search when the question is "the exact set where", not "things similar to".

You can also open a visual view of your graph at any time from the burger menu → **Graph**. Click the **Filter** button in the toolbar to open the filter menu — it lists only the top-level entity types in your schema (Conversation, Person, Task, KnowledgeDocument, …), one row per type, showing your per-type node count and sorted so the most-connected types sit at the top. Child types (messages inside a conversation, sections inside a document), conversation channel variants (admin vs public), message role variants (user vs assistant), and workflow execution plumbing (`ToolCall`, `WorkflowRun`, `WorkflowStep`, `StepResult`) never appear as filter rows — you reach children by clicking the parent and exploring its neighbourhood. Active rows render a force-directed map, coloured by label. Click a node to pivot into its 1-hop neighbourhood; click another node inside that neighbourhood to pivot again. Clicking a Message shows its details in the side panel; the Conversation view stays put — you read sibling messages without losing the chain on canvas. A breadcrumb strip above the canvas shows where you are (`Filter › Conversation › AssistantMessage`). The **Back** control pops one level — three clicks in always undoes with three Back presses; the filter view is the irreducible root. Click the **×** inside the filter menu to clear your chip selection. Type in the search box to highlight matches; submitting a search also widens the filter to include any node types the hits belong to, so relevant matches render instead of disappearing into a "not in current view" banner.

Conversations and Messages carry role/channel sublabels so you can read the chat topology by colour alone — admin vs public conversations and user vs assistant messages render in distinct shades on the canvas. The filter menu intentionally does not split them into separate rows — the base chip is the entry point; you see the variants as colours once you're inside a neighbourhood.

**Save a default view:** once you have the rows you want, click **Set default view** in the filter menu. Next time you open **Graph**, those rows are pre-selected and your data renders immediately. The default is per-admin, per-account — each admin on each account has their own.

**Delete a node:** drag it to the trash icon top-right of the canvas. No confirmation — deletes are reversible for 30 days. To restore, toggle **Show trashed** inside the filter menu and click **Restore** on the node, or ask Maxy in chat ("restore the <label> I just deleted"). Deleting a conversation also trashes its messages in the same step, so they reappear together on restore.

**Bulk cleanup of conversations in chat:** when you ask Maxy to clean up conversations in bulk ("trash all empty conversations," "clean up the single-assistant tests"), the agent uses a deterministic selector with a fixed set of filter names — it cannot author custom delete queries. The server re-runs the same filter on every candidate before it trashes, so a stale list can't destroy something the filter wouldn't match now. If the filter matches nothing, Maxy reports "no candidates" and nothing happens.

The page reads only your own brand's Neo4j — a Maxy device and a Real Agent device share no graph state even when on the same laptop. No credentials are required; the view inherits your admin session.

**Typo-proof cypher.** When Maxy runs direct Cypher to answer a relational question, the query is checked against your Neo4j's live label and relationship-type taxonomy before it executes. Cypher that references an unknown name (an edge or label that does not exist in your graph) is rejected for writes and flagged with a warnings header for reads, so Maxy never silently acts on a query that targeted the wrong set of nodes. You should not see this — it runs invisibly — but it is the safety net that stops a fabricated edge name from producing "empty" results that are really just unreachable. Before acting on a bulk operation Maxy surfaces the result count and a sample; if it ever describes a cypher rejection, that means its first attempt was malformed and it corrected itself.

## Bi-temporal timeline events

Every factual statement Maxy extracts from your conversations is stored as a `:TimelineEvent` node. Each event carries two separate timestamps:

- **`occurredAt`** (valid-time) — when the fact was true in the world. Set from the text itself; can reference a date in the past ("Alice joined in 1990" stores `occurredAt = 1990-01-01`).
- **`learnedAt`** (transaction-time) — when the system ingested this event. Always the wall-clock time of the write; never back-dated.

This distinction lets you ask two qualitatively different questions:

- *"What happened to Alice in 1990?"* — query by `occurredAt`.
- *"What did Maxy learn about Alice last Tuesday?"* — query by `learnedAt`.

`memory-compiled-truth-history` returns both fields for every timeline event on an entity under the `timelineEvents` array, alongside the compiled-truth revision history in the `revisions` array.

**Backfill:** Timeline events written before this feature was added have `learnedAt` set to their `createdAt` value by the schema migration. Events without `createdAt` (very old nodes) receive the migration run time as an approximation.

## Write doctrine

Every new node in Maxy's graph is created with at least one connection to an existing node. A contact connects to the conversation or organisation it came from; a task connects to the session that raised it or the entities it will affect; a session summary connects to the conversation it summarises. A node with no connection is noise — it cannot be attributed, traversed, or explained — so the graph refuses to create one. If Maxy ever tries to record something without a link, the write is rejected and Maxy asks you to clarify where it belongs.

Every node also carries a provenance stamp — which agent wrote it, in which session, via which tool. You never see these fields, but they are how operators trace unusual growth back to the code path that produced it, and why your graph stays clean over time.

**Two write surfaces, one substrate.** General agents write through schema-aware helpers — Maxy can record a new contact, a new commitment, a new preference without ever typing a database query, and the helper enforces the connection-and-provenance rule above structurally. The graph-steward role (the specialist Maxy dispatches when you ask for graph hygiene — "merge those two duplicate contacts," "wire those four tasks to the meeting," "rename the legacy label across the graph") additionally has a raw Cypher write tool for the multi-step operations the helpers cannot express. The steward role internalises the same connection-and-provenance discipline in its prompt; a post-write audit emits a warning on every breach so the same rules apply to both surfaces. Both paths feed the same hourly orphan trend and the same forensic provenance fields — read-side, you cannot tell the two apart, and that is the point.

## Vertical schemas

On top of the base graph, each brand boots one optional **vertical** — an extra set of entity types tailored to a trade. The vertical is named by `brand.json#vertical` and defined in a `schema-<name>.md` reference; the memory plugin loads it at startup and validates every write against base + the active vertical. Real Agent boots `schema-estate-agent` (Listing, Property, Viewing, Offer). SiteOffice boots `schema-construction`, which adds the building-contractor entities — `Job`, `LineItem`, `Valuation`, `Milestone`, `QuoteDocument`, `VariationNote`, `InboundInvoice`, `SubContractor`, `TimeLog`, `SubInvoice`, `WhatsAppGroup` — grounded in real builder job folders so a job's quote, valuations, variations, supplier invoices, and subcontractor timesheets all hang off one `Job` node. The default Maxy brand boots no vertical (base graph only).

## Public-facing summaries for customer-readable subjects

Some entities in your graph are knowable by people outside your team — companies you work with, projects you've delivered, the business itself. For those entities (Maxy treats `:Organization`, `:Concept`, `:Project`, and `:LocalBusiness` this way), Maxy maintains two summaries: a private one only you and your specialist agents see, and a customer-facing public one your public agents are allowed to surface.

Whenever Maxy updates the private summary on one of these entities, it automatically rewrites the public summary in the same step using a separate prompt that strips operator-voice ("needs follow-up", "action: chase next week"), internal sentiment, and anything that reads like a note-to-self. The two summaries stay in lockstep without you doing anything.

If you want to write the public summary yourself — for instance, because the auto-generated version misses something you want customers to see — just tell Maxy the wording you want for the public summary on that entity, and Maxy will write it directly. It stays locked in for seven days; after that, the next automatic refresh can take over again, unless you re-pin it.

People entries (`:Person`) are deliberately excluded from this dual-summary system. Notes about contacts are private by definition and never get a public-facing form.

## Privacy

All memory is stored on your local Raspberry Pi. The Neo4j database never leaves your network. Maxy does not sync memory to any cloud service or third party.

If you want to wipe everything and start fresh, ask: "Reset my memory graph." Maxy will ask for confirmation before doing so.

---
# Projects
Source: https://docs.getmaxy.com/projects-guide.md

# Projects Guide

## What a Project Is

A project is a named body of work with multiple steps, dependencies, and a lifecycle. Unlike standalone tasks, a project has child work items that can depend on each other, a health signal that tracks progress, and lifecycle phases (planning, active, blocked, verifying, complete, abandoned).

Projects are ideal when the user has work involving multiple people, sequential steps, or deliverables — a kitchen refit, a client engagement, a product launch.

## Creating a Project

Tell Maxy naturally:

- "Create a project for Mrs. Chen's kitchen refit — strip the old kitchen, plumbing first fix, electrical first fix, install units, then tiling and finishing"
- "Set up a project for the bathroom renovation, standard tier, due by end of June"
- "Start a project: boiler install for Sarah Thompson, quick job, just order parts, install, and test"

Maxy creates the project and all work items in one step. Dependencies between steps (e.g., "install units after plumbing and electrical") are set up automatically based on the order and relationships you describe.

Each project has a tier that reflects its complexity:
- **Quick** — straightforward, few steps (e.g., boiler install)
- **Standard** — moderate complexity, multiple phases (e.g., kitchen refit)
- **Full** — significant scope, many dependencies (e.g., new build project)

## Checking Project Status

Ask naturally:

- "What are my projects?"
- "How's the kitchen refit going?"
- "Show me the Davies bathroom project"
- "What should I focus on?"

Maxy shows project health at a glance:
- **Green** — on track, no issues
- **Amber** — warning signs (overdue task or blocker)
- **Red** — at risk (multiple overdue, critical blocker, or stale)

When you start a new conversation, Maxy automatically shows active project summaries so you know where things stand without asking.

## Updating a Project

Tell Maxy when things change:

- "Move the kitchen refit to the active phase"
- "The materials for the kitchen refit are delayed by a week"
- "Update the Davies bathroom target date to July 15th"
- "Change the boiler install to a standard tier — it's more complex than we thought"

Maxy records phase changes and issues as part of the project's history, creating an audit trail.

## Completing a Project

Tell Maxy:

- "Mark the boiler install as done"
- "Complete the kitchen refit project"

If any work items are still pending, Maxy will let you know and ask how to handle them — cancel, defer, or keep working on them.

## Abandoning a Project

If a project is no longer needed:

- "Abandon the Davies bathroom — client cancelled"
- "Stop the kitchen refit project"

Maxy records the reason and marks the project as abandoned.

## Projects vs. Tasks

Use a **task** for standalone work — a single action, a reminder, a follow-up. Use a **project** when the work has multiple steps that depend on each other, a client or stakeholder, and a lifecycle that progresses through phases.

When you describe multi-step work, Maxy will ask if you'd like to structure it as a project. Over time, it learns your preference and stops asking.

## Working a Task End to End

Ask Maxy naturally:

- "What's outstanding?"
- "What's on my plate?"
- "What should I work on next?"
- "Pick something to do"

Maxy reads the ready set for your account, groups the open Tasks under their parent Projects, and asks you to pick one. You pick — it never auto-selects.

Once you pick, the loop runs end to end:

1. **Grounding** — pulls the Task and its surrounding context (parent Project, related documents, prior conversation) so the run starts from what is already known, not a blank slate.
2. **Delegation** — routes the work to the right specialist surface. A research task goes to deep-research, an email reply to email composition, a document to professional-document, and so on. Nothing is reimplemented inline.
3. **Write-back** — every artefact produced (document, email, ingested file) is linked to the Task in the graph, progress is logged on the Task, and the Task is closed when done.

This means you can always trace a finished piece of work back to the Task that asked for it, and a Task that says "complete" always has its output attached.

Cross-account access is refused. A Task that belongs to a different account on the same install is invisible to this loop — Maxy will not read it, name it, or surface it.

---
# Slides
Source: https://docs.getmaxy.com/slides.md

# Slides — user guide

The Slides plugin turns a description into a finished, self-contained HTML slide deck. The output is one `deck.html` file — inline CSS and JavaScript, no build step, no dependencies beyond Google Fonts. Open it in any browser, navigate with arrow keys or swipe, and press `P` to export to PDF.

## When to use it

- You need a deck and want to describe what to say rather than format slides by hand.
- You have an existing `deck.html` and want to add slides, restyle it, or get a critique.
- You want a presentation that lives as plain text — version-controllable, diffable, and readable without a binary editor.

## The commands

- **`/slides`** — generate a complete deck from a description. Auto-detects the best storytelling format (talk, pitch, sales, board, product launch) and writes a single `deck.html`. You can pass the brief inline (`/slides "a 20-minute talk on AI-assisted development"`) or run it bare and describe in the follow-up.
- **`/slides-outline`** — draft just the structure (section-by-section) without producing HTML, so you can agree the narrative first.
- **`/add-slide`** — insert one or more slides into an existing deck, matching its theme and components.
- **`/slides-theme`** — restyle a deck. Three built-in themes: **Default** (warm, editorial), **Craft** (richer textures, art overlays), **Solid** (glass morphism, gradients). Any other argument is treated as a custom theme described in text or extracted from an attached image.
- **`/slides-review`** — critique the current deck on storytelling, design, and voice consistency.
- **`/slides-new-component`** — build a new reusable slide component aligned with the design system's tokens.
- **`/slides-claus`** — generate a deck using the Solid theme with the Claus storytelling structure.

## Storytelling formats

`/slides` routes to one of six narrative structures based on your brief: conference **talk** (TED-style), **Sequoia** investor pitch, McKinsey **SCR** (situation-complication-resolution), **product launch**, **board update**, and **sales**. Name the format in your brief to force one explicitly.

## What you get

A 25-component design system (title slides, metric cards, quotes, comparison tables, timelines, and more), responsive layout, and three interchangeable themes — all inside one portable file.

## Notes

This is a frozen, vendored copy of the open-source Slides™ framework (MIT). It ships installed and available on every account; enable it per session like any other plugin if it is not already active.

---
# Telegram
Source: https://docs.getmaxy.com/telegram-guide.md

# Telegram Guide

## What the Telegram Plugin Does

The Telegram plugin connects Maxy to a Telegram bot. Once set up, you can:

- Send messages to individuals or groups via Maxy ("Send a message to the team: standup in 10 minutes")
- Receive messages from your Telegram bot and have Maxy respond
- Use Telegram as a channel for Maxy notifications and alerts

## Setup

### Step 1: Create a Telegram bot

1. Open Telegram and search for `@BotFather`
2. Send `/newbot` and follow the prompts to choose a name and username
3. BotFather will give you a token — it looks like `123456789:ABCdefGhijklMNOpqrstUVWxyz`
4. Keep this token — you'll need it in the next step

### Step 2: Connect the plugin

Tell Maxy: "Set up Telegram" or "Configure the Telegram bot."

Maxy will ask for your bot token, then save it and activate the plugin. The bot is now connected.

### Step 3: Start the bot

In Telegram, open your bot and send `/start`. The bot is now active and listening.

## Sending Messages via Maxy

Once connected, tell Maxy to send messages on your behalf:

- "Send a Telegram message to John: I'll be 10 minutes late"
- "Message the team channel: server maintenance at 11pm"
- "Tell Sarah via Telegram that the proposal is ready"

Maxy needs a chat ID or username to target a specific person or group. For groups, you'll need to add the bot to the group first.

## Getting a Chat ID

To message a specific person or group, Maxy needs their chat ID. The easiest way:

1. Have the person (or yourself) send any message to your bot
2. Ask Maxy: "What chat IDs have messaged the bot recently?"
3. Maxy will look up the message history and show you the IDs

## Message History

Ask Maxy: "What messages has the bot received?" or "Show recent Telegram activity."

## Troubleshooting

**Bot not responding:** Check that the bot token is correct — ask Maxy "What's my Telegram bot token configured as?" and verify it matches BotFather.

**Can't send to a group:** The bot must be a member of the group. Add it via the group settings in Telegram, then try again.

**Messages not arriving:** Make sure the bot hasn't been blocked. Try sending `/start` to the bot directly.

---
# Outlook
Source: https://docs.getmaxy.com/outlook-guide.md

# Outlook Plugin — Operator Guide

The `outlook` plugin gives the admin agent read-only access to Microsoft 365 / Outlook.com via Microsoft Graph. Per-account OAuth (Auth Code + PKCE), encrypted token storage, automatic refresh.

## Quickstart

1. **Register an Entra app once per Maxy install** — see `platform/plugins/outlook/references/auth.md` for full steps. Set `OUTLOOK_CLIENT_ID` (and `OUTLOOK_TENANT_ID`, default `common`) in the operator's environment.
2. **Per account: register the Outlook account** — in admin chat, ask the agent to "register my Outlook account". The agent runs `outlook-account-register`, which prints an authorization URL.
3. **Open the URL in the VNC browser** — sign in to your Microsoft account, consent to the requested scopes (`offline_access`, `User.Read`, `Mail.Read`, `Calendars.Read`, `Contacts.Read`).
4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.

## Tools

| Tool | Purpose |
|------|---------|
| `outlook-account-register` | Run the PKCE flow for this account. One-time per account; re-run if tokens expire (90 days) or consent is revoked. |
| `outlook-mail-list` | Recent mail. Default top=25, folder=Inbox. |
| `outlook-mail-search` | Microsoft Graph `$search` over the mailbox. |
| `outlook-calendar-list` | Calendar events in next rangeDays days (default 7, max 365). |
| `outlook-calendar-event` | Full detail of a single event by id. |
| `outlook-contacts-list` | Top contacts. Default top=50. |
| `outlook-mailbox-info` | Health probe — auth state, refresh-window, folder count. |

## Observability

All log lines start with `[outlook-mcp]` and write to `server.log`. They are key=value, account-scoped:

| Event | Line shape |
|-------|------------|
| Auth init | `auth-init account=<id> codeChallenge=<sha256-prefix-8> redirectPath=<callback-path>` |
| Auth callback | `auth-callback account=<id> elapsedMs=<N>` |
| Auth ok | `auth-ok account=<id> graphUserId=<id> scopes=<csv> tokenExpSec=<N>` |
| Token refreshed | `token-refreshed account=<id> oldExpSec=<N> newExpSec=<N>` |
| Refresh failed | `token-refresh-failed account=<id> reason=<err>` (terminal) |
| Mail list | `mail-list account=<id> folder=<id-or-Inbox> count=<N> elapsedMs=<N>` |
| Mail search | `mail-search account=<id> query=<trunc-32> count=<N> elapsedMs=<N>` |
| Calendar list | `calendar-list account=<id> rangeDays=<N> count=<N> elapsedMs=<N>` |
| Calendar event | `calendar-event account=<id> eventId=<trunc-12> elapsedMs=<N>` |
| Contacts list | `contacts-list account=<id> count=<N> elapsedMs=<N>` |
| Mailbox info | `mailbox-info account=<id> tokenWithinRefreshWindow=<bool> folderCount=<N>` |
| Graph error | `graph-error account=<id> status=<N> code=<graphErrorCode> retryAfterMs=<N-or-null>` |
| On-prem rejected | `on-prem-rejected account=<id> mailServer=<host>` (terminal) |

## Diagnostic paths

```bash
# All outlook lines for one account, last 50
ssh laptop 'grep -E "^\[outlook-mcp\]" ~/.maxy/logs/server.log | grep "account=<id>" | tail -50'

# Token-leak audit — must always return zero
grep -rn -iE "Bearer |access_token=" ~/.maxy/logs/server.log | head
```

Latency triage: `mail-list count=0 elapsedMs<200` consistent → permissions issue; `elapsedMs > 5000` → Graph slowness or DNS.

## Failure modes

| Operator-visible message | Cause | Fix |
|---|---|---|
| `Outlook not connected for account=X; run outlook-account-register` | Tokens never saved | Run register tool |
| `Outlook refresh token expired for account=X; run outlook-account-register` | >90 days since last refresh, or consent revoked | Run register tool |
| `Outlook token refresh failed for account=X; re-auth required` | Network down at refresh time, or refresh token invalidated | Verify network; re-register |
| `Outlook auth expired for account=X; run outlook-account-register` | Refresh-then-retry still got 401 | Re-register |
| `Outlook rate-limited without Retry-After hint` | Graph 429 with no backoff guidance | Wait + retry; if persistent, file bug |
| `Microsoft Graph does not support on-premises Exchange. Use earlier platform fixes (IMAP).` | Mailbox is on hybrid Exchange | Use the `email` plugin |

## Out of scope

Write tools (send, draft, move, flag), OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.

---
# LinkedIn Extension
Source: https://docs.getmaxy.com/linkedin-extension.md

# LinkedIn Extension — operator guide

Capture a LinkedIn profile or DM thread to your Maxy graph with one click. The plugin ships a small Chrome extension; the admin already knows how to receive its payloads.

## Install (one time)

1. Open `chrome://extensions`.
2. Toggle **Developer mode** on (top right).
3. Click **Load unpacked**.
4. Select `platform/plugins/linkedin-extension/extension/` on disk.
5. Click the puzzle icon → pin **Maxy LinkedIn Ingest** → open its options.
6. Paste two values:
   - **Admin host** — your tunnel URL, e.g. `https://your-tunnel.example.com`.
   - **Session key** — open your admin browser, copy the value of `session_key` from the cookie. (Same key the admin uses to authenticate every other admin request.)
7. Save. The pill is now armed.

## Use

- **Profile** — open any `https://www.linkedin.com/in/<slug>/` page. An **Add to Maxy** pill appears in the top section. Click it. Within a few seconds the pill turns green: the profile is in your graph.
- **DM thread** — open any `https://www.linkedin.com/messaging/thread/<id>/` conversation. The same pill appears. Click it; the full transcript is captured plus the participants and any explicit commitments (meetings booked, actions promised, prices discussed).
- **Re-click** — the pill is idempotent. Re-clicking the same URL refreshes the document body and regenerates the summary; identities and entities `MERGE` rather than duplicate.

## What lands in the graph

Every click produces **one `:KnowledgeDocument`** keyed on the page URL, holding the verbatim scraped text as its body and a child `:Section:Note` for the LLM summary. Structured entities layer on top, but only when the body **assertively states** them:

- A `:Person` for the profile subject (or each thread participant), `MERGE`d on canonical keys.
- A `:Organization` for an asserted current employer, with a `WORKS_AT` edge from the person.
- An `:Event` with `ATTENDED_BY` edges when a meeting time is explicitly proposed and confirmed.
- A `:Task` with `RAISED_BY` / `ABOUT` edges when an action is promised without a specific time.
- A `:Service` / `:PriceSpecification` when an offer is discussed.

Soft signals ("interested in chatting", "would love to compare notes") stay in the document body. They are never promoted to graph nodes.

The plugin will never create `:Communication`, `:ConversationArchive` rows (i.e. KnowledgeDocument nodes that carry `conversationIdentity`), or `:Message` nodes — those shapes are reserved for other flows (live chat, archive ingest).

## When the pill turns amber

The pill shows **Sign in to Maxy** when your session key has expired. Click it to open the options page; paste a fresh `session_key` from your admin browser; save. The next click on the LinkedIn pill will succeed.

## When the pill turns red

- **Missing: ...** — LinkedIn shipped a DOM change and the extractor cannot find a required field. Open a console tab on the LinkedIn page and check the `[linkedin-ext-scrape]` log line for the field names. Drop a ticket pointing at the affected selector; the [`SKILL.md`](../../plugins/linkedin-extension/skills/linkedin-extension/SKILL.md) lists the selector table and the steps for adding a fallback.
- **Capture failed** — the admin reached, but the request did not complete cleanly. Check the admin logs (`journalctl -u maxy.service | grep linkedin-ingest`). The `[linkedin-ingest-route]` lines name the reason (`schema`, `dispatch-failed`).

## Related plugins

- **linkedin-import** — bulk ingest of the LinkedIn ZIP export (history). Different surface; both ship and complement each other.
- **memory.document-ingest** — the generic ingest pipeline this plugin's payloads route through. Future communication surfaces (email, Telegram, WhatsApp) plug in here too.

---
# Admin Chat Attachments
Source: https://docs.getmaxy.com/attachments.md

# Admin Chat Attachments

What you can drag-and-drop into the admin chat window, what happens to each file, and the size caps.

## Accepted file types

| Type | MIME | Notes |
|------|------|-------|
| Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | Rendered inline by the agent when relevant. |
| PDF | `application/pdf` | The agent reads the text; scanned PDFs go via OCR if available. |
| Plain text, Markdown, CSV, HTML | `text/plain`, `text/markdown`, `text/csv`, `text/html` | Read directly. |
| Calendar | `text/calendar` | Ingested into the graph if the agent finds a reason to keep it. |
| Voice note | `audio/*` | Transcribed before the message is routed to the agent. |
| **Zip archive** | `application/zip`, `application/x-zip-compressed` | Unpacked by the agent after safety checks. See below. |

Anything else is refused at upload time with a message naming the type.

## Size caps

- **Per file:** 50 MB. Enforced at the upload endpoint — files over this limit never reach disk.
- **Per message:** up to 5 files.
- **Uncompressed contents of a single zip:** 100 MB. A zip whose declared uncompressed total is over this limit is refused before any byte is extracted (decompression-bomb guard).

## What happens with a zip archive

When you drop a `.zip` into chat, the agent:

1. **Checks the archive is safe.** It refuses archives that try to write outside their own extraction folder, contain symlinks, are password-protected, or declare more than 100 MB of uncompressed content. You'll see the exact reason in chat if any check fails.
2. **Extracts it to a fresh folder.** Contents land under `{your-account-dir}/extracted/{id}/` — one folder per archive, never mixed.
3. **Lists what's in it.** The agent tells you the top-level entries, the total file count, and the uncompressed size.
4. **Asks before doing anything else.** For each class of file (text/markdown, images, PDFs, other), it proposes one next step — for example "ingest these notes into memory" or "re-attach the images back to chat so you can see them" — and waits for you to say yes.

Nothing is ingested, sent, or acted on automatically. The extraction is local and visible; you decide what happens next.

## What is **not** supported

- `tar`, `tar.gz`, `7z`, `rar` — zip only. If you have one of these, unzip/convert locally and upload the zip (or the extracted files directly).
- Nested archives — a zip-inside-a-zip is extracted one level; you can ask the agent to unpack the inner one afterwards.
- Password-protected zips — the agent will tell you to unlock locally and re-upload.
- Uploads larger than 50 MB — split the archive, or upload the individual files.

## Where the files live

Uploads go to `{install-dir}/data/uploads/{account-id}/{file-id}/` — outside the platform wipe zone, so they survive re-installs. Extracted zip contents go to `{account-dir}/extracted/{file-id}/`. Both are local to your device.

---
# Answer Engine Optimisation
Source: https://docs.getmaxy.com/aeo.md

# AEO (Answer Engine Optimisation) — user guide

The AEO plugin shapes the site for answer engines (Claude, ChatGPT, Perplexity, Google AI Overviews, Bing Copilot). It does three things: emits schema.org JSON-LD from typed graph entities, generates `/llms.txt` and `/llms-full.txt`, and audits any page against eight heuristics.

## When to use it

- A new page is being authored and you want it to be cited when customers ask an answer engine about your service.
- An existing page is not being cited. Audit it to find the structural reasons.
- You're standing up a new site and want the `llms.txt` pair generated so answer engines can index your content directly.

## The tools

### `aeo-emit-jsonld`

Generates a `<script type="application/ld+json">` block for a typed entity. Two modes:

- **From the graph.** Pass `entityId` (Neo4j elementId of an `Organization`, `Person`, `Service`, `Product`, `CreativeWork`, `FAQPage`, `RealEstateListing`, or `Event`). The tool resolves the entity, maps the label to its schema.org type, and emits the block.
- **Inline.** Pass `label` (one of the supported page types) plus a `properties` object. Use this when the page isn't backed by a stored entity (yet) but you want the JSON-LD shape.

Returns the parsed JSON-LD object and a ready-to-inline script block string. Inline the script in your page `<head>`.

### `aeo-write-llms-txt`

Generates the `llms.txt` / `llms-full.txt` pair for the account. Source is every `KnowledgeDocument` for the account that has a `url` property. Returns both files as strings plus a count of pages skipped because they had no URL.

Wire the output to your site host. Convention: `/llms.txt` (index) and `/llms-full.txt` (concatenated content), served as `text/plain`. Format follows the current draft at `https://llmstxt.org/`.

### `aeo-audit-page`

Runs the eight-heuristic audit. Pass either `url` (the tool fetches) or `html` (you supply the rendered content). Returns:

```
{
  "score": 0–100,
  "heuristics": [
    {
      "name": "structured-answer",
      "status": "pass" | "warn" | "fail",
      "detail": "first <p> after <h1> is 142 chars",
      "suggestion": "Add one ≤280-character <p> immediately after the <h1>…"
    },
    …
  ],
  "target": "<url or '(inline html)'>",
  "audit": { "runAt": "<iso>", "elementId": "<set when persisted>" }
}
```

Pass `persist: true` plus `targetKnowledgeDocumentId` to write the result as an `:AEOAudit` node linked to the document.

## The eight heuristics

| Heuristic | What it checks |
|---|---|
| `h1-present` | exactly one `<h1>` |
| `jsonld-present` | at least one parseable JSON-LD block |
| `structured-answer` | first `<p>` after `<h1>` is ≤280 chars |
| `faq-section` | `FAQPage` JSON-LD on the page |
| `meta-description` | 80–160 char `<meta name="description">` |
| `canonical-url` | `<link rel="canonical">` present |
| `og-tags` | `og:title` and `og:description` both set |
| `heading-hierarchy` | no level skips (h1→h3 etc.) |

`structured-answer` is the highest-impact: that one paragraph is what gets lifted into the engine's answer. A page can fail every other heuristic and still be cited if this one passes.

## Observability

Every tool emits a single log line per invocation:

- `[aeo-emit-jsonld] entityId=… schemaType=… source=graph|inline`
- `[aeo-llms-txt] site=… pages=… skippedNoUrl=… indexBytes=… fullBytes=…`
- `[aeo-audit] target=… score=… fails=… warns=…`

Diagnostic path: `grep -E '^\[aeo-' platform-logs/*.log | grep <urlOrEntityId>`.

## What this plugin does not do

- **No citation monitor — out of scope.** Tracking whether your brand is cited by Claude / ChatGPT / Perplexity / Gemini would require multi-engine answer harvesting that doesn't fit maxy-code's no-API-key architecture. Archived without sprinting (Task 363). Check citation manually when needed.
- **No auto-emission on page render.** `aeo-emit-jsonld` is callable on demand. Wiring it into the platform's page-generator render path is per-renderer work, filed as a follow-up.
- **No publish-hook regeneration of `llms.txt`.** The tool runs on demand. Hooking it into the publish event is a follow-up.

## See also

- Plugin manifest: `platform/plugins/aeo/PLUGIN.md`
- Structured-answer template: `platform/plugins/aeo/skills/structured-answer/SKILL.md`
- Schema declaration: `platform/neo4j/schema.cypher` (search `AEOAudit`)
- Spec source: `https://llmstxt.org/`

---
# Session Retrospective
Source: https://docs.getmaxy.com/session-retrospective.md

# Insight pass

When you type `/insight` on its own in an admin session, the admin agent runs one extra review over the session so far, then carries on. It walks the conversation and writes down four kinds of thing that would otherwise be lost:

- corrections and learnings you gave the agent during the session,
- tonal and working-style preferences worth carrying forward,
- people, decisions, commitments, or business facts that came up but were not yet saved to the graph,
- typed edges between any new prose-bearing nodes (messages, meetings, notes, pages) and the entities they mention — the auto-extraction pass that "wires the graph" so future questions can hop from a person to the companies they founded to the events they attended.

You can run it whenever you want the brain brought up to date — for example before you open a second session that needs the first session's learnings, preferences, or graph writes.

The pass is one or two messages from the agent, then a short summary of what was written. It runs inside the session you are already in: nothing happens in the background, no second session is spawned, and the session is not closed, reset, or cleared — the conversation continues straight afterward against the same live state. The typed-edge pass itself is delegated to the `database-operator` specialist so the writes land where graph writes are supposed to live.

You can tell the pass ran correctly by the final reply naming the five counts (learnings, tonal observations, graph updates, typed edges accepted, prose nodes scanned). The typed-edge pass is scoped by "what changed since the last completed insight pass," so two passes in a row never re-process the same nodes, and a session that never runs one simply defers its extraction to the next time you type `/insight`.

---
# Visitor Graph
Source: https://docs.getmaxy.com/visitor-graph.md

# Visitor graph (Task 357)

Behavioural analytics that connect anonymous page visits to known `:Person` contacts. Replaces the anonymous click-through metric from Task 336 with a fully attributed graph.

## What this gives the operator

- A morning briefing surface: "who has been on the site overnight, and what did they look at?"
- An engagement-ranked nurture queue, ordered by recency × depth × dwell.
- A graph-backed click-through-rate report for property recommendations.
- A full event timeline for any one session, for prep and diagnosis.
- A signed-cookie that recognises a named visitor on later visits without re-clicking the marketing link.

## Data model

| Node | Meaning |
|------|---------|
| `:Session` | One browser tab session. Composite key `(accountId, sessionId)`. |
| `:AnonVisitor` | Pre-identification browser identity. Merges into `:Person` on first signed-token click. |
| `:PageView` | One page load. Carries `referrer`, `path`, optional `dwellMs`. |
| `:Click` | One DOM click on a tagged element (`data-track="<label>"`). |
| `:ScrollMilestone` | Roll-up of scroll depth — one node per `:PageView`, `maxDepth` ∈ {25,50,75,100}. |
| `:Page` | URL metadata. Content-only, survives erasure. |
| `:Recommendation` | Materialised `[property-recommended]` log line for CTR computation. |

| Edge | Direction |
|------|-----------|
| `VISITED` | `Person → Session` or `AnonVisitor → Session` |
| `OWNS_VISITOR` | `Person → AnonVisitor` (cross-session merge) |
| `HAS_EVENT` | `Session → PageView / Click / ScrollMilestone` |
| `OF_PAGE` | `PageView → Page` |
| `OF_LISTING` | `PageView → Listing` |
| `FOR_SESSION` | `Recommendation → Session` |

## How identity gets resolved

The signed-token cookie `mxy_v` carries a `:Person` elementId, signed HMAC-SHA256 with a brand-local 32-byte secret (file at `~/.<brand>/credentials/visitor-token-secret`, minted on first read). When the recommender's `/listings/<slug>/click?session=<sk>&v=<token>` URL is visited:

1. The click handler verifies the HMAC on `<token>`.
2. If valid, the same token value is written into `mxy_v` (Max-Age 30 days, SameSite=Lax, HttpOnly, Secure).
3. On subsequent visits, `POST /v/event` reads the cookie, verifies it, and attributes every event to the bound `:Person`.

When a previously-anonymous browser binds for the first time, any `:Session` already attributed to the `:AnonVisitor` is re-attached to the `:Person`, and the merge fires `[anonvisitor-merge]` with the count of reattributed sessions.

## Tools

All under the `real-agent-buyers` plugin, admin-side only:

| Tool | Purpose |
|------|---------|
| `visitor-recent-by-person` | Recent sessions for a known `:Person` (morning round, 1:1 prep). |
| `visitor-recent-by-page` | Recent visitors of a given listing slug or URL. |
| `visitor-engagement-score` | Engagement-ranked `:Person` list (nurture queue). |
| `visitor-recommendation-ctr` | Graph-backed CTR over a window, joined from `:Recommendation` and `:Click` nodes. |
| `visitor-session-detail` | Full event timeline for one `:Session`. |
| `visitor-event-ingest` | Admin companion to `POST /v/event` for test harness work. |
| `visitor-backfill-from-logs` | One-shot importer: parses `[property-recommended]` and `[property-card-click]` log lines into the graph; used to recover late-arriving sessions and for ad-hoc forensics. |
| `mint-visitor-token` | Mints a signed token bound to a `:Person` for outbound URLs in `morning-round`, `lead-nurturing`, `vendor-updates`. Returns `{ token, expiryMs }`; the agent appends `&v=<token>` to `/listings/<slug>/click?session=<sk>`. Same secret file as the UI server; both processes share it via the wx-create pattern. (Task 362) |

## Privacy

The full description is at `/privacy` on every brand domain. Highlights:

- First-party cookie only, no third-party scripts.
- Retention is erasure-on-request, not time-based; visit data persists until a `:Person` is erased.
- Right-to-erasure cascades through `contact-erase`: `:Session`, every `:HAS_EVENT` child, and any owned `:AnonVisitor` are removed when the `:Person` is erased. `:Page` and `:Listing` are content metadata and intentionally preserved.

## Verification

Quick checks the operator can run after deployment:

1. Load a published listing page; grep `[visitor-event] type=pageview` in `server.log` within 1s.
2. Scroll past 50%; grep `[visitor-event] type=scroll depth=50`.
3. Click an element marked `data-track="floorplan"`; grep `[visitor-event] type=click label=floorplan`.
4. Run `visitor-backfill-from-logs` over a log window where live writes were lost (process restart, etc.); the response reports `recWritten` and `clickWritten` counts. Subsequent runs over the same window are idempotent for `:Recommendation` and append-only for `:Click`.

## Failure signals

| Symptom | What it means | Where to look |
|---------|---------------|---------------|
| `[visitor-event]` count drops to zero with no `[v-event-error]` | Pixel silently failing on the brand domain (probably CSP, CORS, or origin mismatch). | Check brand.json `publishedSiteOrigins`; check browser console on a published listing page. |
| `[token-bind] reject reason=bad-sig` spikes | HMAC verify failing — either the secret rotated and old cookies are being rejected (expected during rotation) or the recommender is minting against a stale secret. | Compare `~/.<brand>/credentials/visitor-token-secret` across processes. |
| `[anonvisitor-merge]` never fires after first signed-token click | The pixel isn't reading the cookie. | Inspect the `mxy_v` cookie in DevTools; check CORS `Access-Control-Allow-Credentials: true`. |
| `[v-event-error] reason=rate-limit` for legitimate operator traffic | Operator IP shares a NAT with high-volume crawlers. | Adjust `RATE_LIMIT` in `visitor-event.ts` or whitelist the IP at the proxy. |

---
# Voice Mirror
Source: https://docs.getmaxy.com/voice-mirror-guide.md

# Voice Mirror Guide

## What It Does

Maxy reads emails, posts, documents, and your own chat messages, and uses them to make sure agent-drafted copy reads like you wrote it — not like generic AI prose.

Anthropic models cannot be fine-tuned. Voice mirror works by feeding the model two things alongside every drafting task:

- A **style card** distilled from your own writing — sentence length, register, favourite phrases, things you never say.
- A handful of **exemplars** — actual paragraphs you wrote, picked for relevance to the current draft.

The model conditions on both and produces copy that reads as yours.

Voice mirror maintains a separate profile for each type of content you write: plain text, email, social posts, articles, notes, and marketing copy. The right profile is applied automatically based on what you're drafting.

When Maxy produces anything that will go out under your name (a document, public-facing copy, anything you will send onward), it applies your voice before it writes the first line, not after you ask for a rewrite. You do not have to request it.

## How Your Voice Is Captured

### Automatically — from chat

Every admin chat session feeds your writing into the `text` profile automatically. When the session ends, Maxy reads the transcript, filters out slash commands, system messages, and large paste blocks, and adds each genuine turn as a corpus entry. This happens in the background with no action needed from you.

### Via backfill — from historical content

Use the backfill flow to teach Maxy your writing from emails, documents, and social posts you've already written.

In the admin chat, ask: **"Start the voice-mirror backfill."**

Maxy asks which stream to backfill first — discrete documents (emails, posts, PDFs) or chat archives (WhatsApp, Telegram, etc.). **Pick chat archives first if you have any imported.** Chat is where your unguarded voice lives; email is the same voice in dress clothes.

- **Chat archives** — tag a whole conversation in one click. Maxy doesn't ask about individual messages because chunks within a conversation almost always share the same voice. Options per conversation: yours, mixed (multiple authors on your side — rare), or not yours (e.g. a Slack channel where you only forwarded other people's messages).
- **Documents and posts** — paginated 10 at a time. Maxy shows each item with its detected format (email, article, note, social-post). Tag the whole batch, a subset by number, or per-item if you want a precise label like `human-led-agent-assisted` (you wrote the content, Maxy polished). Override the detected format if one is wrong.

Skip a batch if none qualify, or stop at any time — the next session resumes where you left off.

## Distilling Your Profile

Once you have corpus entries tagged (or after automatic PTY ingestion fills the `text` profile), ask: **"Build my voice profile."**

Maxy reads the corpus for each format, summarises your style as a YAML card, and saves it to the graph. It picks up your sentence rhythms, the constructions you reach for, the words you avoid — separately for email, articles, social posts, and so on.

The profile re-runs automatically when your corpus grows by ≥20% for any format or every 30 days, whichever comes first.

## Feedback Loop

When you edit an agent draft before sending — shorten a sentence, change a sign-off, swap a phrase — Maxy captures the edit and feeds it into the next distillation. The more you edit, the closer the voice gets.

This happens silently as part of the edit-loop in any drafting skill (email composition is the first wired surface).

## Opt-Out Per Skill

Voice mirror is on by default for every drafting skill. To opt out for one, set `voiceMirror: false` in that skill's frontmatter. The skill falls back to a neutral British business register.

## What It Won't Do

- **Blend voices** — one profile set per person, no "Joel + Neo combined".
- **Copy public figures** — voice mirror only learns from your own writing.
- **Clone audio** — text only, no speech synthesis.
- **Guess** — historical content stays `unknown` until you mark it. Maxy never auto-classifies your writing. (Automatic ingestion applies only to your live PTY sessions where authorship is certain.)

## Status

Voice mirror is live end-to-end. Six corpus formats (text, email, social-post, article, note, marketing-copy), five tools (`voice-tag-content`, `voice-distil-profile`, `voice-retrieve-conditioning`, `voice-record-feedback`, `voice-ingest-session-text`), on-demand session-turn capture, and wiring into the three live drafting surfaces (email composition, property brochures, investor data room) are all live. When no voice profile exists on the account for a given format, every drafting skill degrades gracefully — the output matches what it was before voice mirror was installed.

---
# Admin UI Reference
Source: https://docs.getmaxy.com/admin-ui.md

# Admin UI reference

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

## Scope and tree decision

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

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

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

## Admin Hono routes

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

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

### Sessions and chat

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

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

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

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

### Graph

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

### Artefacts and files

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

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

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

### Browser / device-browser

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

### Diagnostics

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

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

## System-stats widget

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

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

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

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

**Thresholds.**

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

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

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

**Diagnostic grep.**

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

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

## Sidebar surfaces

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

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

## Artefact pane

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

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

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

## Health vs version

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

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

## Related references

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

---
# Graph View
Source: https://docs.getmaxy.com/graph.md

# Graph View

The **Graph** admin page (`/graph`) renders a force-directed view of your
account's Neo4j subgraph. Labels on the canvas follow the zoom level, so you
see the most useful identity at every scale.

## Search and pivot

Type a term in the search box to highlight matching nodes on the canvas. Hits get an amber border so you can pick them out of a busy view. Click any highlighted node to open its side panel and pivot into its neighbourhood — both clicks (hit and non-hit) behave identically.

When a search is active and you click a node, the neighbourhood you pivot into is **narrowed to the search-relevant subset**. For example: searching "david" with 175 matches and clicking yourself returns the Davids you're connected to, not your entire LinkedIn graph. The narrowing applies once per pivot — clearing the search and pivoting again returns the full neighbourhood.

Searches reach **every textual property** of every operator-meaningful label, including denormalised fields the platform writes specifically so search can reach them — for example, the current job title of each LinkedIn connection (originally stored on the connection edge, copied to the Person node so the fulltext index can match it).

Search behaves the same here, in the agent's own memory recall, and on the `/data` page: with **no filter chips selected** a search reaches **all** labels — the same scope the agent sees — and every surface applies the **same default relevance floor**, so the same query returns the same nodes everywhere. There is no longer a "select a chip first" gate; an empty chip set means "search everything". Selecting chips narrows the result to those labels.

## Conversation label tiers

Conversation nodes carry the most operator-meaningful identity in the
subgraph (the conversation name or summary, the date it started, the message
count). They render in one of three tiers, switched by canvas scale:

| Zoom | Label shape | Example |
|------|-------------|---------|
| Zoomed out (< 0.7×) | Compact — one line, capped at 24 characters. Preserves the no-overlap contract that matters when nodes are tightly packed. | `Maxyfi branding conflict…` |
| Mid zoom (0.7× to 1.3×) | Wrapped — up to two lines of 24 characters each, soft-ellipsis on overflow. Full name is visible without hover. | `Maxyfi branding conflict` / `with Rubytech` |
| Zoomed in (≥ 1.3×) | Detailed — wrapped name plus a metadata line reading `YYYY-MM-DD · N msgs`. | `Maxyfi branding conflict` / `with Rubytech` / `2026-04-23 · 7 msgs` |

Non-Conversation nodes (People, Messages, Tasks, WorkflowRuns, etc.) keep
their concise single-line labels at every zoom level — the canvas stays
readable when you zoom out to see a large subgraph.

Tier transitions are debounced so spinning the scroll wheel does not cause
label flicker; labels only rewrite once zoom settles on a new tier.

## Cluster-expand on Conversation/Message clicks (cluster-integrity fix)

Clicking a Conversation node OR any Message node pulls the WHOLE
conversation cluster onto the canvas: the Conversation node itself plus
every Message belonging to it (via `PART_OF`), capped at 200 messages
for layout reasons. The arrow chain along the conversation (the `NEXT`
edges) renders for free because the inter-node relationship pass picks
up edges where both endpoints are in the visible window.

Pre-fix, clicking a middle Message expanded only its prev+next
neighbours; the head, tail, and Conversation node dropped off, visually
disintegrating the conversation. The new behaviour keeps the cluster
intact across click navigation. `PART_OF` edges are now rendered between
visible Conversation/Message pairs (previously suppressed because they
"added no information when the Conversation node wasn't on canvas" — an
assumption that broke the moment the cluster-expand put it there).

The breadcrumb above the canvas tracks each pivot — every entry except
the last is clickable to pop the view-stack back to that point.

## Tooltips and side panel

Hovering a node still shows the full 5-line tooltip (display name, labels,
id, created at, updated at). Clicking a Conversation opens the side panel
with the full property table — zoom-tier changes never alter these paths.

The side panel carries a **Trash** button for live nodes and a **Restore**
button for trashed nodes. Soft-delete is reversible: trashed nodes
remain in the graph and reappear when **Show trashed** is on.

## Deleting a node

Two surfaces, same outcome:

- **Mouse (desktop):** drag a node to the dashed Trash zone in the upper-
  right corner of the canvas.
- **Touch (mobile/tablet):** the dashed Trash zone is hidden because
  vis-network's drag hit-test never fires on touch. Tap the node to open
  the side panel, then tap **Trash**.

Both paths POST to the same soft-delete endpoint; the operator-side
behaviour is identical.

## Mobile layout

Below 640px viewport width the toolbar wraps: the search input claims
its own row, the search-result slider claims its own row (full-width with
an enlarged thumb for touch), and the Filter button + node count share
the bottom row. The "← Back" control collapses to a left-arrow icon to
preserve toolbar space at depth.

## Trashed conversations

Trashed Conversation nodes are hidden by default. Toggle **Show trashed** in
the filter popover to surface them; they render with a faded fill and dashed
border, with their zoom-tier labels intact. The `N msgs` count excludes
trashed Messages, so the detailed-tier label reflects only live turns in the
conversation.

## Filtering by channel and message kind

When you select **AdminConversation** or **PublicConversation** in the
filter popover, two extra rows appear underneath the chip list:

- **Channel** — Web / WhatsApp. Select one to scope the canvas to
  conversations that came in over that channel only. Selecting both is
  the same as selecting neither (all channels). After the migration that
  ships with this release, every conversation carries an explicit
  channel value — pre-existing conversations are backfilled to "Web"
  because only the WhatsApp and Telegram intake paths ever set non-Web
  values.
- **Message** — User / Assistant / WhatsApp. When you've also pivoted
  into a conversation neighbourhood (or your search hits messages
  directly), this row scopes the messages on canvas to the chosen kind.
  WhatsApp messages persist with their own sublabel so you can isolate
  the live-channel cohort from the agent-path cohort within the same
  conversation.

These sub-facets compose with the chip selection. Searching with the
AdminConversation chip selected now also reaches the body text of every
admin message — typing a rare word like "ATM" returns every conversation
that mentions it, not just conversations with that word in the title.

## Sidebar conversations list

The Recents list above the chat sidebar carries a per-row marker:
WhatsApp conversations show a small WhatsApp glyph next to the
conversation name. The dropdown above the list filters Recents to a
specific channel — flipping it to **WhatsApp** hides web-chat
conversations and vice versa.

## Agents in the graph

Both admin and public agents appear as `:Agent` nodes in the graph. Open
the **Agents** entry from the sidebar to see them all. Each agent
carries a `:HANDLED_BY` edge from every conversation it has handled, so
you can pivot from an agent to the conversations it ran. The admin
agent's IDENTITY, SOUL, KNOWLEDGE, and KNOWLEDGE-SUMMARY documents
appear as :KnowledgeDocument nodes connected via `HAS_*` edges, the same
projection shape used for public agents.

## Agent-execution telemetry

`ToolCall`, `StepResult`, `WorkflowStep`, and `WorkflowRun` nodes are
agent-execution telemetry — kept for audit but noisy for day-to-day graph
navigation (they make up roughly 9% of a typical brand's live nodes).
They are hidden from `/graph` by default. To see them — for audit, debug,
or tracing a specific agent run — open the filter popover and tick
**Include agent actions** (the checkbox directly below **Show trashed**).
Flipping it on surfaces the four labels as chips in the popover roster AND
keeps them on the canvas when you pivot into a neighbourhood. The toggle
is session-scoped: every new session starts with agent actions hidden, so
the 90% domain-navigation path stays clean without having to remember to
switch them off. Flipping it off again also drops them from any already-
expanded neighbourhood so a click near a `ToolCall` does not re-introduce
it.

## Direct edge management

`memory-edge` creates or deletes a typed directed edge between two nodes that already exist in the graph. Both nodes must belong to the same account — mismatched or foreign nodes are rejected with a structured error before any mutation runs.

**Create:** MERGE is idempotent. First call returns `{created: true}`; a repeated call with the same endpoints and type returns `{created: false}`. Properties supplied on the call are stamped onto the relationship on CREATE only; a subsequent idempotent hit does not overwrite them.

**Delete:** If the edge is present it is deleted and `{deleted: true}` is returned. If absent, the call is a no-op and returns `{deleted: false}`.

`relationshipType` is uppercase-coerced. Types that start with an underscore (e.g. `_SOFT_DELETE`) are reserved for platform internals and are rejected.

Typical flow: call `memory-search` for each endpoint to retrieve their `elementId` values, then call `memory-edge action=create relationshipType=RELATES_TO fromId=<id> toId=<id>`. The new edge appears when you hop-expand either endpoint on the `/graph` canvas.

---
# Neo4j Edge Types
Source: https://docs.getmaxy.com/neo4j.md

# Neo4j edge types — operator reference

How Maxy's graph wires itself.

## Typed-edge auto-extraction

When the operator types `/insight`, the admin agent delegates one pass to the `database-operator` specialist. That pass reads every prose-bearing node your account wrote since the last completed insight pass — messages, meetings, notes, pages, posts, reports, emails, ideas — and asks Claude Haiku to propose typed edges from the text. Only edges that match a closed allowlist of `(sourceLabel, EDGE_TYPE, targetLabel)` shapes are MERGEd into the graph. The graph wires itself; you do not have to ask for it.

If a session never runs an insight pass, its prose nodes are not lost — the next `/insight`, in that session or a later one, picks them up, because the pass is scoped by what changed since the last completed one rather than by which session wrote it. The cost of skipping is a delay, not lost extraction.

## Typed-edge allowlist

<!-- TYPED-EDGE-TABLE:START -->

<!-- Generated by platform/plugins/memory/mcp/scripts/generate-edge-docs.ts from TYPED_EDGE_ALLOWLIST. Do not edit by hand. -->

| Source label | Edge type | Target label |
|---|---|---|
| Person | ATTENDED | Event |
| Person | ATTENDED | Meeting |
| Person | WORKS_AT | Organization |
| Person | WORKS_AT | LocalBusiness |
| Person | INVESTED_IN | Organization |
| Person | INVESTED_IN | LocalBusiness |
| Organization | INVESTED_IN | Organization |
| Organization | INVESTED_IN | LocalBusiness |
| Person | FOUNDED | Organization |
| Person | FOUNDED | LocalBusiness |
| Person | ADVISES | Organization |
| Person | ADVISES | LocalBusiness |
| Person | ADVISES | Person |
| Message | MENTIONS | Person |
| Message | MENTIONS | Organization |
| Message | MENTIONS | LocalBusiness |
| Message | MENTIONS | Event |
| Page | MENTIONS | Person |
| Page | MENTIONS | Organization |
| Page | MENTIONS | LocalBusiness |
| Page | MENTIONS | Event |
| Meeting | MENTIONS | Person |
| Meeting | MENTIONS | Organization |
| KnowledgeDocument | MENTIONS | Person |
| KnowledgeDocument | MENTIONS | Organization |
| Note | MENTIONS | Person |
| Note | MENTIONS | Organization |
| Idea | MENTIONS | Person |
| Idea | MENTIONS | Organization |
| Post | MENTIONS | Person |
| Post | MENTIONS | Organization |
| Report | MENTIONS | Person |
| Report | MENTIONS | Organization |
| Person | AUTHORED | Post |
| Person | AUTHORED | Report |
| Person | AUTHORED | Page |
| Person | AUTHORED | Note |
| KnowledgeDocument | ATTACHED_TO | Meeting |
| Page | ATTACHED_TO | Project |
| Note | ATTACHED_TO | Project |
| Page | REFERENCES | Page |
| Report | REFERENCES | Report |
| Report | REFERENCES | Page |

<!-- TYPED-EDGE-TABLE:END -->

---
# Platform Internals
Source: https://docs.getmaxy.com/internals.md

# Platform Internals — Retrieval Architecture

Technical architecture reference for the retrieval pipeline, knowledge delivery, and supporting infrastructure. This document covers how information moves from the Neo4j graph into an agent's context — the mechanics behind "Maxy searches this graph to retrieve relevant context."

Use this reference when assessing capabilities, diagnosing retrieval behaviour, or answering questions about how the platform works internally. When a question asks "does Maxy have X?" — check here before asserting a gap.

---

## Retrieval Pipeline Overview

Every knowledge query flows through a hybrid search pipeline that combines semantic similarity with keyword matching, applies layered access controls, expands results via graph traversal, and optionally re-ranks via LLM reasoning.

```
QUERY  ── (retrievalClass from Task 304 gateway-classifier)
  │
  ├── EXPAND (Haiku — 3-5 paraphrases, 1h cache)            [flag: MAXY_GS_EXPANSION]
  │
  ├── ROUTE  (per-class label filter + fusion weights)      [flag: MAXY_GS_ROUTE]
  │
  ├── For each query ────► EMBED ──► VECTOR SEARCH ──┐
  │                                                  ├─► FUSE (weighted-sum or RRF) [flag: MAXY_GS_RRF]
  │                  └────► BM25 FULL-TEXT ──────────┘
  │                         (entity_search — universal coverage)
  │
  ├── BOOST  (compiledTruth +15%, backlinks log 5-25%)      [flag: MAXY_GS_BOOSTS]
  ├── DEDUP  (4 layers: nodeId, slug, canonicalName, hash)  [flag: MAXY_GS_DEDUP]
  ├── THRESHOLD + SORT + SLICE
  └── GRAPH EXPAND ──► RESULTS

Fusion (default / weighted-sum): combined = 0.7 × vector + 0.3 × bm25_norm
Fusion (RRF):                    score = Σ 1 / (60 + rank_i) across ranked lists
Fallback: if the full-text index doesn't exist, vector-only results are returned (graceful degradation, no error).
```

Each Task 308 enhancement is independently flagged. All flags default OFF — the unflagged pipeline is identical to the baseline weighted-sum + nodeId-only-dedup behaviour. Tasks 305 (typed-edge backlinks) and 306 (compiledTruth property) have landed, so the boost data is populated; flag activation, soak windows, and per-flag measurement live under Task 337.

### Hybrid Search Detail

**Vector path:** The query is embedded via Ollama (model per `EMBED_MODEL` env var, default `nomic-embed-text`). The resulting vector is compared against Neo4j's HNSW cosine indexes — one per indexed label. Dimensions are configured at install time (default 768). The search runs against all discovered indexes (or a subset if the caller specifies label filters). Scores are in [0, 1] (cosine similarity).

**BM25 path:** The raw query text is escaped for Lucene special characters and run against the `entity_search` full-text index (earlier platform fixes — universal coverage), which spans every operator-meaningful label written by the platform on the canonical text-property union (~28 properties: `name`, `firstName`, `lastName`, `givenName`, `familyName`, `title`, `summary`, `body`, `content`, `description`, `headline`, `email`, `subject`, `bodyPreview`, etc.). Pre-Task-748 the index was named `knowledge_fulltext` and covered only `KnowledgeDocument | Section | Chunk` — that gap silently hid Person/Organization/Task/Event/etc. from BM25 regardless of query. Raw BM25 scores are in [0, infinity) — they are normalised to [0, 1] via min-max scaling within the result set before merging. When all scores are equal (or a single result), all normalise to 1.0.

**Merge:** Results from both paths are collected in a single map keyed by `nodeId`. A node appearing in both paths accumulates the max vector score and max BM25 score independently. The combined score is `0.7 * vectorScore + 0.3 * bm25Score`. Results are sorted descending by combined score, then sliced to the requested limit (default 10).

### Task 308 enhancements (flagged, default off)

| Stage | Module | Flag | What it does |
|---|---|---|---|
| Routing | `route.ts` | `MAXY_GS_ROUTE` | Picks per-class label filter + fusion weights from the `retrievalClass` hint produced by Task 304's gateway-classifier. `entity` → vector-heavy + Person/Company/Concept; `temporal` → BM25-heavy over Event; `event` → BM25-only over Event; `general` → balanced; `none` → skip the lookup. |
| Multi-query expansion | `query-expansion.ts` | `MAXY_GS_EXPANSION` | Haiku generates 3-5 paraphrases per query; each runs through vector + BM25 in parallel, with results unioned before fusion. Per-call 1-hour cache keyed by (accountId, query, retrievalClass). Graceful degrade on Haiku failure — original query only. |
| RRF fusion | `rrf-fusion.ts` | `MAXY_GS_RRF` | Replaces weighted-sum with Reciprocal Rank Fusion (k=60 by default). Sums `1 / (k + rank)` per node across the ranked lists each pass produces. More robust to score-distribution drift between indexes than weighted-sum. Weighted-sum stays as the fallback. |
| compiledTruth boost | `boosts.ts` | `MAXY_GS_BOOSTS` | +15% to the combined score of any hit whose node carries a non-null `compiledTruth` property (populated by Task 306 on Person/Company/Concept). The property is in the `entity_search` index so BM25 hits against summary text are also matched. |
| Backlink boost | `boosts.ts` | `MAXY_GS_BOOSTS` | `bump = clamp(0.05 + 0.05 × log10(backlinkCount), 0.05, 0.25)`. 1 backlink → +5%; 10 → +10%; 100 → +15%; 1000+ → +20%; capped at +25%. Reads `backlinkCount` populated by Task 305's typed-edge hook. |
| 4-layer dedup | `dedup.ts` | `MAXY_GS_DEDUP` | Strict superset of nodeId-only dedup. Layers: `nodeId`, `slug`, `canonicalName` (case-insensitive, falls back to `name`), `contentHash` (sha256 of `compiledTruth || content`). Highest-score representative wins per collision class. Missing keys skip the layer, no false collision. |

A per-call log line lets the operator see which stages ran with which counts:

```
[graph-search:hybrid] accountId=<8c> retrievalClass=<c> expansions=<n> vector=<n> bm25=<n> fused=<n> boosted=<n> deduped=<n> final=<n> mode=<hybrid|rrf|bm25> ms=<ms> expand-ms=<ms>
```

### What the hybrid approach catches

Vector search excels at semantic meaning — "how do I contact someone" finds nodes about communication even if the word "contact" doesn't appear. BM25 excels at exact terms — invoice numbers, product codes, proper nouns, technical identifiers. The hybrid combination ensures both modes contribute, with semantic similarity weighted higher (0.7) because most user queries are natural language.

---

## Embedding Infrastructure

| Property | Value |
|---|---|
| Model | Default `nomic-embed-text` (via Ollama at `localhost:11434`), configurable at install time via `--embed-model` |
| Dimensions | Default 768, configurable at install time (resolved from model lookup table or `--embed-dimensions`) |
| Similarity function | Cosine |
| Index algorithm | HNSW (approximate nearest-neighbor) |
| Configurable via | `EMBED_MODEL` and `EMBED_DIMENSIONS` env vars (set by installer in `~/{configDir}/.env`), `OLLAMA_URL` |

### Indexed node labels

Every searchable node type has its own vector index. The `memory-search` tool discovers indexes at runtime via `SHOW INDEXES` and caches the label-to-index mapping. This means new index definitions in `schema.cypher` become searchable automatically without code changes.

Indexed labels: `Question`, `DefinedTerm`, `Review`, `Service`, `Person`, `LocalBusiness`, `PriceSpecification`, `Task`, `CreativeWork`, `DigitalDocument`, `KnowledgeDocument` (includes email threads via `source:'email'` since Task 321), `Section`, `Chunk`, `Conversation`, `Message`, `Event`, `Workflow`, `Preference` (18 labels total).

### Full-text index

| Index name | Labels | Properties | Purpose |
|---|---|---|---|
| `entity_search` | All operator-meaningful labels (~40, see [`schema.cypher`](../../../neo4j/schema.cypher)) | Canonical text-property union (~28) | Universal BM25 keyword matching across the whole graph |

### Embedding lifecycle

Embeddings are computed at write time for **every** node of a vector-indexed label, on every write path — the per-node tools (`memory-write`, `memory-ingest`, `profile-update`, `project-create`), the bulk archive ingest (`memory-archive-write`: LinkedIn / Obsidian / Notion), the conversation-insight emitters, and the raw-Cypher admin/scheduling/UI paths. Small entities embed a bounded props representation; document/archive labels embed a bounded `summary` plus per-`:Section` body embeddings (the archive path uses a deterministic sectioniser since it has no LLM classifier). The authoritative indexed-label set is `VECTOR_INDEXED_LABELS` in `platform/lib/graph-write`, pinned to `schema.cypher` by a drift test.

If Ollama is unavailable at write time, the node is stored without an embedding (property-searchable, not vector-searchable) and a `[graph-write] warn reason=embed-failed` line is emitted — a failed embed never aborts the write. The two standing audits in the UI server's `graph-health` timer surface any backlog: `[embed-audit] null-embedding labels=… count=N` and `[account-audit] null-account labels=… count=N` (both healthy at 0).

The `memory-reindex` tool backfills missing embeddings by iterating nodes where `embedding IS NULL`. It uses the bounded representation (the `summary` for document/archive labels, never the raw body), embeds in batches with a per-node fallback on batch error, and **skips and continues** past a failing node — keeping skipped nodes out of the re-fetch via an exclude set — instead of aborting the whole run. `embed()`/`embedBatch()` cap oversized input (`EMBED_INPUT_MAX_CHARS`) before the Ollama POST, so a large body embeds truncated rather than throwing. Per-node `[reindex] op=embedded|skip-error` lines and a terminal `[reindex] op=done processed=N skipped=M`.

---

## Knowledge Document Hierarchy

Large documents are decomposed into a three-level hierarchy for granular retrieval:

```
KnowledgeDocument
  ├── summary (embedded) — document-level semantic anchor
  ├── Section
  │ ├── summary (embedded) — section-level semantic anchor
  │ └── Chunk
  │ ├── summary (embedded) — chunk-level semantic anchor
  │ └── content (raw text, BM25-indexed) — full content for retrieval
  └── attachmentId — links back to the source file
```

All three levels are independently vector-indexed and BM25-indexed. A query may match at the document level (broad topic), section level (sub-topic), or chunk level (specific passage). Graph expansion from a matched chunk retrieves its parent section and document for context.

### Semantic chunking

Documents are split by a semantic chunker that identifies topic boundaries rather than using fixed character counts. Each chunk gets a summary (used for embedding) and retains the raw content (used for BM25 and for returning to the agent).

---

## Response-side `fields` projection

`memory-search` accepts an optional `fields: string[]` that narrows the
`properties` returned on each row to the caller-named keys. This is a
read-side payload trim only — it runs **after** `hybrid()` returns, so
vector search, BM25, keyword subscriptions, and graph expansion all see
the full text. Ranking does not change.

- `fields` omitted → today's behaviour (every property except `embedding`).
- `fields: ["name", "slug"]` → only those keys per row.
- `fields: []` → empty `properties` object — explicit "no properties".
- Unknown keys are silently skipped. Rows lacking a requested key omit it
  on that row.
- `related[*].properties` is NOT projected (separate concern).

Use this when the caller knows which keys it needs (slug → name, Person →
phoneNumber). It is the safe alternative to write-time summarisation,
which is lossy: write-time pruning has no way to know which keys a future
query will want.

Observability: when `fields` is set, `memory-search.ts` writes
`[memory-search] accountId=… fields=… returnedProps=N droppedProps=N` to
stderr. `droppedProps=0` across many calls with `fields` set is a
diagnostic signal — either the schema has already been narrowed upstream,
or callers are requesting every field and defeating the purpose.

## Guard Layers

Every query path — vector search, BM25 search, keyword subscriptions, and graph expansion — applies a consistent set of access control filters. These are Cypher WHERE clauses, not middleware, so they cannot be bypassed by tool parameter manipulation.

### Layer 1: Soft-delete filter

```
WHERE node.deletedAt IS NULL
```

Unconditional. No parameter controls it. Nodes with a `deletedAt` timestamp are excluded from all query paths. Soft-deleted `KnowledgeDocument` nodes cascade the timestamp to all child `Section` and `Chunk` nodes. Grace period before hard deletion: 7 days. Re-ingesting a soft-deleted document (same `attachmentId`) clears `deletedAt` and replaces the hierarchy.

### Layer 2: Scope filter

```
WHERE (node.scope IS NULL OR node.scope IN $allowedScopes)
```

When `allowedScopes` is set (e.g., `["public", "shared"]` for public agents), only nodes with a matching `scope` property — or no scope at all (legacy transitional safety) — are returned. When `allowedScopes` is omitted (admin agent), no scope filtering is applied. Scope values: `public`, `shared`, `admin`.

### Layer 3: Per-agent tag filter

```
WHERE node.agents IS NOT NULL AND $agentSlug IN node.agents
```

When `agentSlug` is set (public agent queries), only nodes explicitly tagged for that agent are returned. The `agents` property is a string array on each node — a node is visible to an agent only if the agent's slug appears in this array. No implicit "available to all" fallback. This is enforced at the MCP server level via the `AGENT_SLUG` environment variable — tool parameter overrides are rejected when the env var is set.

**Defense in depth:** Both scope and agent filters must pass. An admin-scoped node tagged for a public agent is still invisible to that agent because the scope filter rejects it first.

### Layer 4: Graph expansion enforcement

Related nodes discovered during hop traversal are independently filtered:

```
WHERE (related.scope IS NULL OR related.scope IN $allowedScopes)
AND (related.agents IS NULL OR $agentSlug IN related.agents)
AND related.deletedAt IS NULL
```

This prevents cross-agent content leakage via graph traversal — a public agent cannot reach admin-scoped nodes by following relationships from a public node. Untagged related nodes (no `agents` property) pass through, allowing shared structural nodes (e.g., `PriceSpecification` linked to a `Service`) to be discoverable.

### Layer 5: Account isolation

```
WHERE node.accountId = $accountId
```

Multi-tenancy boundary. Every query is scoped to the requesting account. The `ACCOUNT_ID` environment variable is set at MCP server startup — it is not a tool parameter and cannot be overridden by the agent.

The read filter alone is not sufficient — it correctly *hides* alien-account nodes from every UI but does not prevent them existing. A writer that misresolves `accountId` (literal, undefined, or inferred-from-the-wrong-context) leaks nodes into the graph with no downstream symptom; the read filter then keeps them invisible indefinitely. The write-side doctrine is documented in `.docs/neo4j.md` "Account isolation invariant" — every writer that stamps `n.accountId` must verify the value against `${DATA_ROOT}/accounts/<id>/account.json` before write. The live floor is `writeNodeWithEdges` — every doctrine-primitive write is gated by an `accountId == process.env.ACCOUNT_ID` check (the spawning process validates `ACCOUNT_ID` at boot against the on-disk account set via the `account-enumeration` lib), with `[graph-write] reject reason=invalid-account-id …` as the rejection signal.

**Two boot-time surfaces stamp + validate the env** (added 2026-05-07). The brand systemd unit emits `Environment=ACCOUNT_ID=<uuid>` (resolved by the installer from `INSTALL_DIR/data/accounts/<uuid>/account.json`); the Hono boot path then calls `validateAccountIdEnv` against the on-disk set and emits `[graph-health] account-id-env present=true id=<8> matches-on-disk=true` on success or `[graph-health] account-id-env FATAL reason=<missing|no-on-disk-account|mismatch>` + `process.exit(1)` on failure. No fallback — a misconfigured Pi cannot silently boot.

---

## Query Classification

Before searching, a Haiku classifier decides whether a query needs knowledge retrieval at all. This prevents meta-queries ("hello", "thanks", "continue") from polluting the system prompt with irrelevant search results.

| Property | Admin variant | Public variant |
|---|---|---|
| Model | `claude-haiku-4-5` | Same |
| Timeout | 3 seconds | Same |
| History window | Last 4 messages (2 user + 2 assistant) | Same |
| Max tokens | 200 | 120 |
| Query rewriting | Yes — resolves references from history into concrete search terms | Same |
| Topic-change detection | Yes — detects shifts with confidence score | No (removed, earlier platform fixes) |
| Fallback on failure | `search: true` (always search with raw message) | Same |

### Classification output

The classifier returns a JSON object:
- `search` (boolean) — whether a knowledge search should run
- `query` (string or null) — a search-optimised rewrite of the user's message, or null to use the raw message
- `reason` (string) — short explanation of the decision

When `search` is true and `query` is non-null, the rewritten query replaces the raw message for the memory-search call. This is important: the classifier resolves pronouns and references from conversation history into concrete terms, improving retrieval precision.

### Knowledge retrieval gate

The public agent is toolless by construction (Task 615): it has no `memory-search`, no graph access mid-turn, and no tools of any kind. KNOWLEDGE.md (when present) plus SOUL are assembled into the agent's system prompt at spawn time and are the entire knowledge surface. Every `role=public` spawn (webchat, whatsapp, telegram) resolves an empty allowlist and runs in `dontAsk` (Task 506) with a per-spawn `permissions.deny` covering every native, harness, and memory-MCP tool (Task 612). The spawner anchors the empty allowlist with a single non-native deny-basis token (`mcp__none__deny-basis`) so `--allowed-tools` is always present and native-excluding (Task 609) — without it, `dontAsk` would have nothing to deny against and the brand `allow:["*"]` would re-open native tools.

### The canonical public-agent locator (Task 638)

There is exactly one place each datum is read from, and every subsystem reads it the same way:

- **Default agent + account id** come from the canonical account config `data/accounts/<id>/account.json`, resolved via `resolveAccount` / `resolveDefaultAgentSlug` / `getDefaultAccountId` (`app/lib/claude-agent/account.ts`). The UI server's bare-root handler and branding loader read it through these helpers — never from `~/.<brand>/account.json`, which nothing writes. A `null` default means a genuinely unset `defaultAgent`; the root logs `[public-root] op=serve-default slug=<s>` on success and `op=no-default` only when the canonical default is truly empty.
- **Identity files** (IDENTITY.md, SOUL.md, KNOWLEDGE.md) come from `agents/<slug>/` — keyed on the agent's slug, never the literal role. A public agent renamed to a slug `≠ "public"` (e.g. `maxy-info`) spawns from `agents/maxy-info/`. Admin reads `agents/admin/`. The spawn composer and the standing reachability audit resolve this directory through one shared function, `resolveAgentIdentityDir(accountDir, role, slug)` (`claude-session-manager/src/agent-identity-locator.ts`), so the path the audit vouches for is byte-identical to the path the spawn reads — the audit's `reachable=true` implies the spawn will find the files, and a slug whose files are misplaced is reported `reachable=false reason=files-missing file=<path>` without a visitor request.

### Observability

Admin: `[admin-query-classifier]` log line with `topicChange`, `topicChangeConfidence`, `existingTopic`, `latencyMs`.

Public: `[public-query-classifier]` log line with `search`, `effectiveQuery`, `reason`, `latencyMs`. The intentional absence of topic-change fields in the public log is the on-disk evidence that the public path does less work.

---
---

## Reports — durable workflow output (Task 332)

The `:Report` label is the platform's durable shape for workflow output the operator may want back later — daily briefings, dream cycle runs, ad-hoc analyses. Three MCP tools own the surface, all on the memory plugin:

- `memory-report-write` — append-only writer. Validates body ≤ 10,000 chars, embeds title+body, and CREATEs a `:Report` node. Idempotent on `(accountId, title, occurredAt-within-same-minute)` — a second call with the same title in the same minute returns the existing node instead of duplicating. Parented to the active `:Conversation` via `:PRODUCED` when `SESSION_NODE_ID` is set (the chat-driven default); falls back to the account's `:AdminUser` so the graph-hierarchy doctrine holds even outside a conversation.
- `memory-report-read-latest` — fetches the newest `:Report` (default `limit=1`) tagged with a given keyword. The expected route for any operator phrasing of "latest X", "last night's X", "show me X report".
- `memory-report-list` — metadata-only paginated listing (newest first), with optional `keyword` and `sourceWorkflow` filters. Use to scan the catalogue without paying for full bodies.

Every operation emits one log line: `[reports] op=<write|read-latest|list> reportId=<short> keywords=<csv> ms=<n>` (with `idempotent=1` on a write that resolved to an existing node, `hits=<n>` on reads, `total=<n>` on list).

Routing is not classifier-side. The admin agent's `IDENTITY.md` carries the rule under **Recalling reports**: "latest <X>" / "last night's <X>" / "show me <X> report" → first tool call is `memory-report-read-latest`. The intent classifier (Task 304's `retrievalClass`) already differentiates temporal vs entity vs event reads; reports route off the literal phrase, not a new class.

The first caller is the `briefing` skill (`platform/plugins/scheduling/skills/briefing/SKILL.md`), which persists each run as a `:Report` with `title: "Daily briefing <YYYY-MM-DD>"`, `keywords: ["daily-briefing", "<YYYY-MM-DD>"]`, `sourceWorkflow: "daily-briefing"`. Dream-cycle (Task 327) and ad-hoc analyses are expected to follow the same pattern.

---

## Graph Expansion

After the top results are selected (by combined score or by LLM ranking), each result node is expanded by traversing its immediate relationships.

### Traversal mechanics

```cypher
MATCH (n)-[r]-(related)
WHERE elementId(n) = $nodeId
AND related.deletedAt IS NULL
AND (related.scope IS NULL OR related.scope IN $allowedScopes)
AND (related.agents IS NULL OR $agentSlug IN related.agents)
RETURN type(r), direction, labels(related), related
LIMIT 20
```

- **Default hop depth:** 1 (immediate relationships only)
- **Related nodes cap:** 20 per primary result
- **Direction tracking:** Each relationship is labelled `outgoing` or `incoming`
- **Scope enforcement:** All guard layers (soft-delete, scope, agent) apply to related nodes
- **Configurable:** `expandHops: 0` produces compact output (properties only, no related nodes) — useful for listing/inventory queries

### What expansion provides

A `Service` node matched by vector search will have its `PriceSpecification`, `Review` nodes, and parent `LocalBusiness` attached as related nodes. A `Chunk` matched by BM25 will have its parent `Section` and `KnowledgeDocument`. This context enrichment means the agent receives not just the matched node but its immediate neighbourhood in the graph.

---

## Keyword Subscriptions — Reactive Per-Agent Knowledge

Each public agent can subscribe to up to 5 keywords via `knowledgeKeywords` in its `config.json`. These subscriptions make the agent reactive to new graph content matching its topics — content added after the agent was created becomes discoverable without manual tag updates.

### Dual search per keyword

For each subscription keyword, two complementary searches run:

1. **BM25 full-text search** — queries the universal `entity_search` index with the keyword as the search term. Catches content that mentions the keyword in its text across every operator-meaningful label.

2. **Property-based search** — finds nodes whose `keywords` array property contains the subscription keyword (case-insensitive). Catches nodes explicitly tagged with that keyword topic. These matches are boosted to maximum BM25 score (1.0) since they are exact tag matches.

Both searches run **without** the per-agent tag filter (`agentSlug`) — keyword subscriptions are scope-inclusive by design, meaning an agent's subscriptions can discover content not directly tagged for it. The scope filter (`allowedScopes`) still applies as defense in depth — admin-only content remains invisible to public agents regardless of keyword matches.

### Union semantics

Results from keyword subscription searches are merged into the same scored map as the primary vector+BM25 results. Deduplication by `nodeId` with `Math.max` on scores means a node found by both direct search and keyword subscription keeps the highest score from each method.

### Lifecycle

Keywords are consumed by the `update-knowledge` admin skill when regenerating KNOWLEDGE.md — the regeneration query broadens the operator-tagged set with keyword matches so newly-added graph content that shares a subscribed topic lands in the next baked snapshot. There is no runtime keyword-injection path on the public PTY surface.

---

## Conversation Search

Separate from the knowledge retrieval pipeline, `conversation-search` provides semantic search over past messages.

- **Index:** `message_embedding` (768-dim cosine HNSW on `Message` nodes)
- **Scope:** When `SESSION_ID` is set (public agent), results are limited to that conversation. Admin searches all conversations.
- **Output:** Messages with role, content, timestamp, and relevance score.

This tool is read-only and available to both public and admin agents.

### When conversations are created

`:Conversation` nodes on webchat (admin login, "New conversation" in the burger, a new public visitor) are created lazily. Opening the chat or logging in does not write anything to the graph — Maxy only records the conversation once the user sends a second message. This keeps `conversation-search` and the Conversations modal free of one-turn abandoned threads. WhatsApp and Telegram take the opposite posture: every inbound — DM or group, allowed or activation-off, agent-invoked or gated — MERGEs the `:Conversation` and writes a forensic `:Message:WhatsAppMessage` row before any access-control decision. The graph is the durable record of every message the device received, not just the ones the agent replied to. See `.docs/web-chat.md` "Deferred conversation persistence" and `.docs/whatsapp.md` "Session continuity" for the full contract.

Each row in the Conversations modal exposes a `View logs` row-action that opens a popover with three links — **Stream**, **Errors**, **SSE** — each of which targets `/api/admin/logs?type={stream|error|sse}&sessionId={full-id}` in a new tab. The row's 8-char id chip is click-to-copy; hover reveals the full `sessionId` as a tooltip. See `.docs/web-chat.md` "In-chat retrieval" for the route contract and `console.debug` observability.

### Static publish surface — `/sites/*`

Maxy hosts a generic per-account static-tree publish surface at `https://public.<brand>/sites/<...>/<file>`. The route serves files from `<accountDir>/sites/<...>` with URL=disk mirroring — operator drops the tree on disk, no upload API. Extended MIME covers HTML/CSS/JS/woff2/fonts on top of images. Path traversal (`..`, encoded `..`, segments failing `SAFE_SEG_RE`) returns 403; symlinks escaping the sites root are rejected via a `realpathSync` re-check. `.html` responses carry `Content-Security-Policy: default-src 'self' https: data:; script-src 'none'` and `Cache-Control: no-cache`; assets are cached for an hour; every response carries `X-Content-Type-Options: nosniff`. Per-account isolation comes from `resolveAccount` — every brand's install sees only its own tree.

**Directory canonicalisation.** A request whose disk target is a directory is `301`'d to the trailing-slash form (query string preserved) before any body is served — RFC 3986 §5.3 base resolution requires the trailing slash so relative refs in the served HTML resolve under the directory, not its parent. After the redirect the route serves `<dir>/index.html` if it exists on disk; otherwise `404`. There is **no** implicit-`index.html` invention for missing paths — the publisher owns canonical URLs. A brochure shipped without `index.html` is reached at `/sites/<slug>/<file>.html`, and the admin skill `publish-site` is the sanctioned surface that moves the extracted tree under `<accountDir>/sites/<slug>/` and emits the canonical path slug. Operator-side: drop a brochure at `<accountDir>/sites/properties/<id>/brochure/output/` and it serves at `<public-host>/sites/properties/<id>/brochure/output/brochure.html` (or `<public-host>/sites/properties/<id>/brochure/output/` if that directory contains an `index.html`). See `.docs/web-chat.md` `/sites/*` route entry for the wire contract and `[sites]` log lines (`serve|redirect-trailing-slash|not-found|path-traversal-rejected|symlink-escape-rejected|no-account`).

**Deterministic public-hostname surface.** The `<public-host>` half of the URL the operator pastes is resolved by the `mcp__plugin_admin_admin__public-hostname` MCP tool. It reads `<configDir>/cloudflared/config.yml` (ingress list) then falls back to `<configDir>/alias-domains.json` — the same two files `cloudflared` and `platform/ui/server/index.ts`'s `isPublicHost()` already trust to route. Returns `{hostname, isApex, source}` on hit (`source` is `"cloudflared-config.yml"` or `"alias-domains.json"`), or `{hostname:null, source:null, reason:"no-tunnel"}` on miss. Tiebreak: apex wins over subdomain (single-label, or `www.<apex>` stripped). `publish-site` step 6 calls it after the move and emits the full URL (`https://<hostname><path-slug>`) in the same turn. Graph queries are no longer involved — any earlier graph-backed resolver returned `(none)` on accounts bootstrapped without `cloudflare-task-tracker.ts` writes (laptop Real Agent, manual `cloudflared` setup), the `llm-framing-deterministic` recurrence class. The graph-mcp shim additionally runs a sequential envelope-warning probe on every read response — when Neo4j emits `gql_status` codes matching `^0[12]N5\d$` (e.g. `01N52` "property does not exist"), the shim stitches them into a prefix content block on the response so property-name misses surface to the agent inline instead of returning silent `[]`. Probe failure is best-effort: the upstream response forwards unchanged with `[mcp:graph] probe-error`.

### Cross-tab session rotation

When you click "New conversation" in the chat tab, Maxy mints a fresh admin session key on the server and clears the old one. Sibling admin tabs (`/graph`, `/data`) opened in the same browser keep working without re-login: the chat tab broadcasts the new key on a same-origin channel so each sibling tab updates its captured key instantly, and any in-flight admin request that 401s with the rotation-orphan code retries once after re-reading the latest key from per-tab storage. If neither path recovers (browser locked down, second 401 after retry, session expired), the tab shows a single banner — "Your admin session was renewed in another tab. Click to reload." — and one click sends you back through login. No silent 401s; no re-clicking through the same trash icon hoping it sticks. See `.docs/web-chat.md` "Cross-tab rotation contract" for the wire-level `code` taxonomy and observability surfaces.

---

## Context Assembly — How Retrieved Knowledge Reaches the Agent

The final step in the retrieval pipeline is injecting retrieved content into the agent's system prompt. The path depends on agent configuration.

### Channel spawn routing by role (Task 626)

The manager exposes three named spawn routes: `/rc-spawn` (a live `claude --remote-control` PTY — the operator sidebar, the channel admin, and the one-shot admin jobs), `/public-spawn` (the renamed `/spawn` — `spawnClaudeSession`, the zero-tool public surface), and the `rc-daemon` it drives. No route named `/spawn` remains; a manager boot line `[spawn-routes] live=[rc-spawn,public-spawn]` asserts this.

The channel PTY-bridge (`ensureEntry`) routes each inbound by role: an **admin** WhatsApp/Telegram inbound spawns on `/rc-spawn` (keyed by a deterministic per-sender sessionId so the thread resumes across restarts) and drives every turn via `/<id>/input`; a **non-admin** inbound spawns on `/public-spawn`. Each dispatch logs `[<channel>-adaptor] route role=<role> target=<rc-spawn|public-spawn> senderId=…`. LinkedIn ingest and the public session-end review also run on `/rc-spawn`, carrying their prompt as `initialMessage` with `closeAfterTurn` so the PTY stops after one assistant turn.

### Public agent paths

Public agents run on the same native Claude Code PTY surface as the admin, dispatched through the channel PTY-bridge with `role: 'public'`. The agent's directory files (IDENTITY.md, SOUL.md, KNOWLEDGE.md, KNOWLEDGE-SUMMARY.md when present) are assembled into the system prompt at spawn time. There is no per-turn server-side knowledge injection.

The public agent is toolless by construction (Task 615): `memory-search` and every other tool are excluded from the per-spawn `--allowed-tools` allowlist on every public channel, and a per-spawn `permissions.deny` blocks them outright (Task 612). The agent has no graph access mid-conversation; KNOWLEDGE.md is the ceiling of factual knowledge.

### KNOWLEDGE.md staleness guard

When both `KNOWLEDGE.md` and `KNOWLEDGE-SUMMARY.md` exist, the server compares modification times. If KNOWLEDGE.md is newer than the summary (summary is stale), the full KNOWLEDGE.md is used. Otherwise, the summary is preferred (smaller token footprint).

### Admin agent path

The admin agent runs via Claude Code CLI, which manages its own system prompt assembly. Knowledge reaches the admin agent through MCP tools — `memory-search` is the read-path entry point (server-side LLM ranking was removed by Task 424; the agent ranks in-turn against any criterion). The admin agent also receives session context via `loadSessionContext`, which injects:

- Recent review digest (last public chat or review digest `CreativeWork`)
- Open tasks (priority-ordered, capped)
- Active review alerts (unsuppressed, last 24 hours, capped at 5)

This is assembled as a `<previous-context>` block in the system prompt on each admin turn.

### fetchMemoryContext — the MCP bridge

For public agents, the server calls the memory MCP server via JSON-RPC over stdin/stdout:

1. Spawn the memory MCP server as a subprocess with environment variables: `ACCOUNT_ID`, `ALLOWED_SCOPES=public,shared`, `AGENT_SLUG`, `KNOWLEDGE_KEYWORDS`, `SESSION_ID`
2. Send `initialize` + `tools/call` (name: `memory-search`, arguments: `{query, account_id}`)
3. Read the tool result text
4. Timeout: 8 seconds. On any failure, returns null — the agent proceeds without memory context.

This subprocess model means each public agent query gets an isolated, short-lived memory server instance with the correct scope constraints baked into its environment.

---

## Output Formatting and Budget

The `memory-search` tool formats results as structured text with labels, properties, scores, and related nodes. An output character budget of 80,000 characters prevents results from exceeding Claude Code's tool result token limit (~100K chars). When results exceed the budget, related nodes are progressively dropped (compact mode) to fit within the limit.

Each result is formatted as:
```
[Label1, Label2] (id: nodeId) (score: 0.XXX)
  property1: value
  property2: value
  Related:
    --[RELATIONSHIP]--> [RelatedLabel] {prop1: val, prop2: val}
    <--[RELATIONSHIP]-- [RelatedLabel] {prop1: val, prop2: val}
```

Results are separated by `---` dividers. The `embedding` and `accountId` properties are stripped from output (internal fields, not useful to the agent).

---

## Index Discovery and Schema Evolution

The memory MCP server does not hardcode index names. On first query, it runs `SHOW INDEXES YIELD name, labelsOrTypes, type WHERE type = 'VECTOR'` and builds a label-to-index-name map. This map is cached for the lifetime of the process.

This means:
- Adding a new vector index in `schema.cypher` makes a new label searchable without code changes
- The `memory-reindex` tool can backfill embeddings for newly indexed labels
- Index renames are transparent — the server discovers the current index names at startup

The cache is cleared via `clearIndexCache` after schema changes (e.g., after `memory-reindex` detects new indexes).

---

## Inbound Message Gateway

Every inbound message — regardless of channel (web admin, web public, WhatsApp DM, WhatsApp group) — passes through a centralised screening and classification step before reaching the agent. One Haiku call per message produces:

- **Content screening** — CLEAN / SUSPICIOUS / DISCARD verdict plus a prompt injection flag. DISCARD verdicts on public channels return a polite refusal without invoking the agent. Admin messages receive advisory screening only — flagged in the log but never blocked or modified.
- **Query rewriting** — retrieval-optimised reformulation of the message for memory-search (public channels only; admin text is unchanged).
- **Intent classification** — question / instruction / complaint / greeting / follow-up.
- **Language** — ISO 639-1 code.
- **Complexity** — simple / complex.

Short messages (under 5 words) skip the Haiku call but still get local pattern matching against the shared prompt injection vocabulary — this prevents short injection payloads from bypassing screening.

On Haiku timeout, API error, or missing API key, the raw message passes through unmodified (graceful degradation). The gateway never blocks the user from reaching the agent due to its own failure.

Gateway results are injected into the agent's system prompt as structured metadata, giving the agent context about the message before it begins processing.

### Diagnostics

Every gateway invocation logs to `server.log` with the `[inbound-gateway]` tag, including channel, verdict, intent, language, complexity, latency, and fallthrough status. Non-clean verdicts get an additional warning log.

To check recent screening activity:
```
grep '[inbound-gateway]' server.log | tail -20
```

---

## Tool Eagerness — eager-load vs deferred

The Claude Code SDK marks every MCP tool as **deferred** by default. The model cannot invoke a deferred tool until it has first paid a `ToolSearch` round-trip to load the schema — one extra turn per unique schema. Built-in SDK tools (`Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `Agent`, `WebSearch`, `WebFetch`) stay eager. There is no count threshold; the gate is per-tool.

The SDK's per-tool override is `_meta["anthropic/alwaysLoad"]: true` on each MCP tool's `tools/list` entry. Two surfaces apply it:

1. **In-process plugins.** Every admin-eager tool is registered via `eagerTool(server, name, description, inputSchema, handler)` from `platform/lib/mcp-eager/` instead of `server.tool(...)`. The helper calls `server.registerTool` with the `_meta` flag set.
2. **Upstream graph proxy.** The upstream Python `mcp-neo4j-cypher` server has no `_meta` channel, so `platform/lib/graph-mcp/src/index.ts` intercepts every `tools/list` response on the wire and injects `_meta["anthropic/alwaysLoad"]: true` into each tool entry. The `[graph-mcp] tools/list eager-flagged count=<N>` stderr line confirms the injection fired.

**Curation rule.** Every MCP tool the admin agent calls routinely should be eager — registered via `eagerTool` (or arriving through the graph-mcp interceptor). Whether a tool is eager is decided at its registration site in the plugin's MCP `index.ts` (`eagerTool` vs `server.tool`); there is no separate allow-list constant. Admin-skill / specialist / public-agent tools that stay on `server.tool()` pay the ToolSearch tax only when their caller invokes them. The admin tool surface (`toolSurface.admin`, the `adminAllowlist: true` set) is the intended eager set; a routinely-called admin tool left on `server.tool()` is a gap to fix at the registration site.

**Observability.** Spawn-time emit: `[tool-surface] session=<convId> permission_allowed=N eager_intent=E eager_set_size=T`. Turn-end emit: `[admin-agent] turn-end ... toolsearch=N toolsearch_unique=U`. A non-zero `toolsearch` on a fresh turn for an eager-intended tool means a plugin reverted to `server.tool()` — fix at the plugin's MCP registration site, not the allow-list.

## Spawn-time MCP and subagent registration

Each `claude` PTY spawn registers every callable MCP server and every dispatchable subagent before the operator's first turn. **Platform MCP servers come from one channel — installed plugins — for admin and specialist spawns (Task 502).** Claude Code's plugin system serves every plugin MCP tool under the long prefix `mcp__plugin_<plugin>_<server>__<tool>` (for platform plugins `plugin == server == directory`), which is the canonical name the admin `--allowed-tools` argv and every specialist `tools:` frontmatter bind to. Admin spawns no longer write a per-spawn `.mcp.json` or pass `--mcp-config`; the per-account env (`ACCOUNT_ID`, `USER_ID`, `NEO4J_URI`, `NEO4J_PASSWORD`, `PLATFORM_ROOT`, `CLAUDE_CONFIG_DIR`) rides the PTY env block.

**Public agents are the one exception.** A public-facing web agent is toolless by construction (Task 615), so public spawns retain the per-spawn `mcp-config.json` (`--mcp-config <path>`) but register **zero** servers in it — the file carries an empty `mcpServers`. Combined with the empty `--allowed-tools`, the `dontAsk` mode, and the per-spawn `permissions.deny` (Task 612), no tool reaches an anonymous visitor on any channel. `--strict-mcp-config` (which only ever guarded auto-discovery of a project `.mcp.json`) is retained on the public per-spawn path so no project file is discovered either; it is dropped from admin spawns that no longer pass `--mcp-config`.

For subagents, the same spawn pushes `--add-dir` for every bundled plugin agents directory (`platform/plugins/*/agents/`, `premium-plugins/*/agents/`) — both roles — plus the per-account specialists directory `<accountDir>/specialists/agents/` (admin only). Claude Code's `subagent_type` dispatch reads the agent file off disk via the added directories; without `--add-dir` the dispatcher returns "no matching agent."

A boot gate refuses to start the manager when any admin-allowlisted tool `mcp__<plugin>__*` lacks a registered server. The signal is `boot-failed reason=mcp-allowlist-without-server plugin=<p> tool=<t>` followed by `process.exit(1)`. The remediation is a one-line edit to the named `PLUGIN.md`: add the `mcp:` block. The complementary observability emit `mcp-config-allowlist-coverage admin-tools=A admin-registered=R` (where `A === R`) confirms the invariant per boot.

A second boot gate walks every specialist `.md` under `platform/templates/specialists/agents/`, every bundled `<plugin>/agents/` directory, and the per-account `<accountDir>/specialists/agents/` directory, parses each file's `tools:` frontmatter line (canonical long-prefix names since Task 502), and classifies every tool name as one of: CC-native (Read, Bash, …), a tool the loaded `PLUGIN.md` set actually serves (matched as the long canonical name in `toolSurface.all`), a third-party MCP bridge (a `mcp__plugin_*` name whose plugin segment is NOT a maxy platform plugin — Playwright etc., upstream-owned, passes unconditionally), `unknown-tool-in-plugin` (maxy plugin namespace served but tool name absent), `unknown-plugin-namespace` (namespace served by nothing), `brand-excluded-plugin` (namespace served by nothing on this brand, **but** the brand's `brand.json#plugins.excluded` list names it), or `malformed-name` (not CC-native and not `mcp__`-shaped). The first three pass. The next two refuse boot with one `boot-failed reason=specialist-tool-drift specialist=<name> tool=<t> drift=<class> path=<…>` line per defect, then `process.exit(1)`. A maxy-plugin `mcp__plugin_*` name is validated against `toolSurface.all`, so a typo or stale long-prefix tool name still refuses boot rather than passing as a bridge; the build-time `check-canonical-tool-names.mjs` gate catches the same drift in instruction files before publish. `brand-excluded-plugin` is a structural pass: it lands in a per-specialist strip-list, the manager continues to boot, and at spawn time `pty-spawner` removes those tool names from the `--agent <name>` spawn's `--allowed-tools` argv. The complementary observability emit `specialist-tool-strip specialist=<name> plugin=<p> tools=<csv> reason=brand-excluded` fires one line per stripped (specialist, plugin) pair so an operator who reads `server.log` sees the brand filter doing work without cross-referencing `brand.json` against the template. The startup-self-test line `startup-self-test specialist-tool-drift=ok inspected=<N> stripped-specialists=<M>` confirms the gate ran and how many specialists carry strip-lists.

This gate was Task 173. The `brand-excluded` branch closes the recurring crash-restart loop on brands that ship without a plugin the shared `personal-assistant.md` template references (e.g. `realagent-code` excludes `telegram` while the template hard-codes `mcp__telegram__*`). The brand-agnostic template stays a single file; the brand-aware filter expresses what the specialist *may* do on this install while the template expresses what it *can* do across brands. Tool typos and renamed plugins still refuse to boot — only namespaces explicitly named in `plugins.excluded` are demoted to strip-and-warn.

**Brand-foreign premium bundles (Task 343 / Task 344).** Task 344 closes the loop one layer up: the installer bundler at [`packages/create-maxy-code/scripts/bundle.js`](../../../../packages/create-maxy-code/scripts/bundle.js) now applies the same `brand.json#shipsPremiumBundles` gate at *payload assembly time*, so foreign bundles never reach disk on the device. The gate is shared with the test suite via [`scripts/premium-bundle-gate.mjs`](../../../../packages/create-maxy-code/scripts/premium-bundle-gate.mjs) and accepts only two shapes — `undefined` / missing → ships nothing; `string[]` → ships only the named bundles. The legacy boolean `true` form is **rejected**: bundle.js hard-fails with `FATAL: brand.shipsPremiumBundles must be a string[] (boolean 'true' no longer accepted; enumerate bundles in <brand.json>)`. An allowlist entry naming a bundle directory that is absent on disk is also FATAL — silent over-shipping is the failure mode this gate exists to prevent. Each build emits one `[bundler] premium-bundle-gate brand=<n> mode=<m> shipped=[…] skipped=[…]` line. The runtime gate `walkPremiumBundles` at [`plugin-manifest.ts`](../../../ui/app/lib/claude-agent/plugin-manifest.ts) keeps the same shape and stays as defence-in-depth — on a correctly bundled payload, it walks only allowlisted bundles because foreign ones are not present. The drift-gate's `agents-dir-skipped reason=brand-foreign-bundle` line therefore fires only when something has staged a foreign bundle out-of-band.

**Structured journald mirror for boot-failed (Task 343).** Every `boot-failed reason=specialist-tool-drift …` line is mirrored to journald via `systemd-cat -t maxy-csm -p err` with the fields `specialist=`, `tool=`, `drift_reason=`, `agent_path=` so `journalctl --user -u <brand>-claude-session-manager.service -t maxy-csm` can filter by any of them without grep on `server.log`. The stdout line stays unchanged so the existing diagnostic one-liners keep working. `systemd-cat` absence (e.g. macOS dev box) is swallowed — the stdout line is the primary surface; the structured emit is auxiliary.

**Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.

**Channel follower cold-start retry (Task 610).** Each channel PTY session (webchat, whatsapp, email) has one JSONL follower ([`platform/ui/app/lib/channel-pty-bridge/follower.ts`](../../../ui/app/lib/channel-pty-bridge/follower.ts)) reading `GET /<sessionId>/log?follow=1` and fanning each assistant `end_turn` out to the awaiting `dispatchOnce`. A freshly-spawned PTY has no JSONL on disk until claude flushes its first line; during that window the manager answers `202 {pending:true}`. The follower retries every `CHANNEL_PTY_FOLLOWER_RETRY_MS` (default 1000) until a 200 stream opens or `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` elapses. The follower is shared across channels, so that window defaults to the longest channel turn window (whatsapp's `WHATSAPP_PTY_TURN_TIMEOUT_MS`, 300000 — longer than webchat's 120000) so it never abandons a turn the caller is still awaiting. Because public sessions idle-reap, every webchat greeting is the first turn of a fresh spawn and crosses this window — before the retry, a 202 (which satisfies `res.ok`) was consumed as a single non-event line, the stream ended, and the follower died silently, timing out every public turn. The lifecycle is greppable as `follower-connect status=<code>` → `follower-retry attempt=N reason=pending` → `follower-open` → `outbound bytes=N`; `follower-give-up reason=pending-timeout` marks the JSONL never appearing. A `reject reason=turn-timeout` with no preceding `follower-open` (and no manager `log-follow-open`) for that sessionId is the Task 610 signature. See `.docs/gated-public-agents.md` "Webchat turn lifecycle" for the full tag list.

**Brand-process start counter (Task 173).** `platform/ui/server-init.cjs` increments a persistent counter at `/tmp/server-init-<accountId>-restart.count` on every fresh start and emits `[server-init] start count=<N> account=<accountId> counter-path=<…>` to `server.log`. /tmp clears on reboot, so a clean reboot starts the count fresh; any value `>1` between operator-observed reboots means the brand process (driven by its `Requires=<brand>-claude-session-manager.service` clause) is restarting. The diagnostic one-liner is `grep '\[server-init\] start' ~/.<brand>/logs/server.log | tail -5` — the trailing `count=` value is the loop depth without counting SIGTERMs.

**Programmatic spawn entry point.** The Sidebar new-session-with-prompt click routes through the single cookie-auth wrapper (Task 626 removed the recorder loopback caller) at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts). The wrapper owns the per-spawn enrichment (owner profile, dormant/active plugins, specialist domains, tunnel URL) and the `senderId` resolution; it forwards a single `POST /public-spawn` to the session manager on `127.0.0.1`, with `initialMessage` inlined on that body. The manager appends `initialMessage` as the trailing positional argv to `claude`, so the CLI processes it as the session's first user turn at PTY startup — no separate `POST /<sessionId>/input` call, no bracketed-paste. (Task 153.) See `admin-session.md` "Spawn-with-initialMessage wrapper" for the body schema and caller list.

**End-turn auto-close (lifecycle, not user-initiated).** The session manager's `attachEndTurnAutoClose` ([`platform/services/claude-session-manager/src/http-server.ts`](../../../services/claude-session-manager/src/http-server.ts)) wires a one-shot job's JSONL to a watcher: as soon as it contains `"stop_reason":"end_turn"`, the manager calls `stopSession`, the PTY exits, the PID file is removed, and `fs-watcher.ts` demotes the row to `state: 'archived'`. It fires for `/public-spawn` database-operator specialist spawns and for `/rc-spawn` jobs spawned with `closeAfterTurn` (LinkedIn ingest and the public session-end review — Task 626). This is the lifecycle archive path — the row stays in place, the JSONL stays on disk, no directory move. It is structurally distinct from the user-initiated `POST /api/admin/claude-sessions/:id/archive` route, which actually `mv`s the JSONL between `<slugDir>` and `<slugDir>/archive/`.

## Tool Call Audit Trail

Every tool invocation by the admin agent produces a durable `ToolCall` node in the knowledge graph, linked to the `Conversation` that triggered it. This covers all admin agent tool calls — the full history of what the agent did, when, and in what context.

Each ToolCall record contains:

| Field | Description |
|-------|-------------|
| toolName | The MCP tool that was invoked (e.g. `memory-search`, `workflow-execute`) |
| pluginName | The plugin that owns the tool |
| input | Truncated JSON of the tool's input arguments |
| output | Truncated response text |
| isError | Whether the tool call resulted in an error |
| startedAt / completedAt | Timestamps for the invocation |
| sessionId | Links back to the originating conversation |

Records persist indefinitely and are queryable by the admin agent. Ask Maxy "what tools ran in the last session?" or "show me all tool calls from today" to review the audit trail.

Workflow-dispatched tool calls are tracked separately via `StepResult` nodes (part of the workflow execution system) and are not duplicated as ToolCall nodes.

### Diagnostics

Tool call persistence logs to `server.log` with the `[persist]` tag:
```
grep '[persist] tool-call persisted' server.log | tail -10
```

Each log entry includes the tool name and a truncated conversation ID for correlation.

## Process provenance — durable actions emit Tasks

Every durable action — cloudflare tunnel-login, brand publish, future deterministic flows — emits a `:Task {kind:"<flow>"}` node carrying the action's lifecycle and a `:PRODUCED` edge to every entity the action created. This makes the graph traversable from the originating Conversation to every entity created during it via `(c)<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e)` — answering "what did this turn produce" in one Cypher hop.

The doctrine is observed at the storage primitive: writes to `:Person`, `:UserProfile`, `:AdminUser`, `:Organization`, `:LocalBusiness`, `:CloudflareTunnel`, or `:CloudflareHostname` should carry an inbound `:PRODUCED` edge whose source is one of `:Task`, `:Conversation`, or `:Message`. Subtype labels like `:AdminConversation`, `:UserMessage`, `:AssistantMessage`, `:AdminMessage` qualify because the gate checks the full `labels()` array. Bootstrap writes (PIN-setup, schema migrations, lazy first-session UserProfile creation) are exempt via `createdBy.agent === 'system'`. When no qualifying edge resolves, the primitive emits a `[graph-write] warn reason=missing-provenance labels=<csv> agent=<agentLabel>` line and the write proceeds (Task 580 relaxed this from a hard reject — the composer-spawned admin path inherits a bare per-account env that never receives the `SESSION_NODE_ID` stamp, so the throw was failing every direct admin contact-create / memory-write for a gated label).

Two surfaces emit the lifecycle: agent-driven actions call `work-create`/`work-update`/`work-complete` over MCP (`work-create` accepts `kind`, the canonical `inputsProvided` call-shape record, `inputs` + `inputSchema` for the operator-meaningful form payload, and `raisedDuringConversationKey` to resolve the `RAISED_DURING` edge). Shell-driven actions wrap their script invocation in [platform/ui/app/lib/cloudflare-task-tracker.ts](../../../ui/app/lib/cloudflare-task-tracker.ts) (cloudflare is the first; installer / brand-publish / OAuth-login deferred). Both surfaces emit the same `[task] action-start|step|done` log lines so operators can grep one channel uniformly. Both also call the central `redactSecrets` primitive ([platform/lib/task-secrets/](../../../lib/task-secrets/)) to strip schema-tagged secret keys before persisting `inputs.<field>` props on the Task — see `.docs/neo4j.md § Audit Task input contract` for the contract that replaces per-kind allow-lists.

Two surfaces feed the gate. (1) **Workflow path:** `memory-write` accepts an optional `producedByTaskId` parameter. When set, an inbound `:PRODUCED` edge from that Task is composed into the write's relationships before the gate runs — the typical agent-side pattern is to call `work-create` at the start of an autonomous flow, capture `taskId`, and pass it as `producedByTaskId` on every subsequent `memory-write` for a gated label. The gate verifies Task and write share the same `accountId`; mismatch is rejected loud. (2) **Direct-ask path:** the admin server resolves the active `:AdminConversation`'s `sessionId` UUID and stamps it as `SESSION_NODE_ID` in the spawn env at PTY-spawn time. The same stamp propagates onto specialist subagent spawns the admin dispatches (Task 382) so listing-curator, content-producer, database-operator etc. inherit the same conversation anchor. The `contact-create` and `memory-write` wrappers call `injectConversationProvenance` (exported from [`@maxy/graph-write`](../../../lib/graph-write/src/conversation-provenance.ts)) which MATCHes `(c:Conversation {sessionId, accountId})` — account isolation is part of the natural key, not a separate gate — and prepends the synthetic `:PRODUCED` edge (composed by Neo4j elementId, which the helper reads off the MATCH). No agent-visible schema field changes. `memory-write` uses the env-stamp only as a fallback when `producedByTaskId` is unset; `contact-create` has no `producedByTaskId` parameter today and relies on the env-stamp alone. Autonomous (cron-driven) specialists with no parent conversation legitimately have no env-stamp; those must thread `producedByTaskId`.

Operator audit cyphers:
- "What entities did this conversation's actions produce?" — `MATCH (c:AdminConversation {sessionId:$id})<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e) RETURN labels(e), e.name, t.kind, t.status`
- "What cloudflare resources did this tunnel-login produce?" — `MATCH (t:Task {kind:'cloudflare-tunnel-login', status:'completed'})-[:PRODUCED]->(r) RETURN t.taskId, r.tunnelId, r.hostnameValue ORDER BY t.completedAt DESC`

See `.docs/neo4j.md § Process provenance doctrine` for the full enforcement contract, observability surface, and out-of-scope deferrals.

## Context compaction

When an admin turn crosses 75% of the model's context window, Maxy runs a silent compaction turn that asks the agent to call the `session-compact` MCP tool with a structured briefing (what you asked for, what was done, decisions made, work-in-progress, things you've shared about yourself). The briefing is written to Neo4j; the next admin turn injects it back into the system prompt, so continuity survives across the compaction boundary without re-sending the full transcript.

The compaction runs against a transient one-shot pool entry separate from the long-lived admin Query. Operator-visible side effects:
- Compaction logs land in `claude-agent-compaction-stream-YYYY-MM-DD.log` alongside the main stream log. Look for `[compaction-start]`, `[compaction-summary-captured]`, `[compaction-failed]`, `[compaction-timeout]`, `[compaction-crashed]`, or `[compaction-spawn-error]` to triage. Subprocess stderr is captured inline as `[subproc-stderr] <line>` — there is no longer a separate `claude-agent-compaction-stderr-…log` file.
- The one-shot pool entry's lifecycle is greppable as `[client-cold-create] reason=compaction-one-shot …` paired with `[client-evict] reason=compaction-one-shot …`, distinguishable from the regular admin pool's lifecycle tags.

---
# Deployment Guide
Source: https://docs.getmaxy.com/deployment.md

# Deployment Guide

## Hardware Requirements

- Raspberry Pi 5 (16GB RAM minimum) with Raspberry Pi OS, **or**
- Mac with macOS 14 (Sonoma) or newer — both Apple Silicon and Intel
- 256GB storage minimum
- Always-on power and network connection

## macOS install

On macOS the installer uses Homebrew + launchd instead of apt + systemd. No flags are required on a laptop:

```bash
npx -y @rubytech/create-maxy-code            # default brand
npx -y @rubytech/create-realagent-code       # realagent brand
```

**Prerequisite:** Homebrew. If `brew` is missing, the installer refuses with `Homebrew not found. Install from https://brew.sh and re-run.` Install Homebrew once via the official one-liner, then re-run. The installer never installs Homebrew itself.

**Hostname / printed URL.** Without `--hostname`, the installer reads `scutil --get LocalHostName` and prints the completion URL as `http://<that-name>.local:<port>`. No sudo, no system change — your Mac's existing local network name is what mDNS will resolve. With `--hostname <h>`, the installer sets `HostName` / `LocalHostName` / `ComputerName` to `<h>` via `sudo scutil` (one password prompt, all-or-nothing rollback within the three-call batch) so the URL becomes `http://<h>.local:<port>`. Grep `~/.<brand>/logs/install-*.log` for `[create-maxy] darwin-hostname-mode=` to confirm which path ran (`scutil-get`, `scutil-set`, or `brand-fallback`).

**LaunchAgent.** The installer registers `Maxy` as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.<brand-hostname>.plist` — for example `com.rubytech.maxy-code.plist`. Survives logout/login and reboot via `KeepAlive=true` and `RunAtLoad=true`. Two brands on the same Mac get two distinct plists (brand-hostname-keyed), so install order is independent. Use `launchctl print gui/$UID/com.rubytech.<brand-hostname>` for service state. The `[create-maxy] launchd-plist=<path> loaded=true` line in the install log confirms `launchctl bootstrap` accepted the plist; `loaded=false exit=<n>` is the failure signal (run `plutil -lint <path>` to diagnose).

**Cloudflare on darwin.** The installer brew-installs the `cloudflared` binary so it is on PATH, but does not invoke `cloudflared service install` or `cloudflared tunnel route dns` — public reach is opt-in. After install, the operator runs `cloudflared tunnel login` (browser-driven) followed by the existing tunnel-setup flow if they want a public address. Grep `[create-maxy] darwin-cloudflare-skip=true` in the install log to confirm the installer took the documented skip path.

**Uninstall.** `npx -y @rubytech/create-maxy-code uninstall` (or the realagent equivalent) bootsout the LaunchAgent, removes the plist, and deletes `~/.<brand>/`. Homebrew-installed dependencies (curl, git, unzip, jq, poppler, ffmpeg, node@22, neo4j, cloudflared) remain — remove them with `brew uninstall` if you want a clean slate.

**Pre-flight.** macOS < 14 is refused at pre-flight via `parseSwVers` (`sw_vers -productVersion` must be `≥ 14`).

**Diagnostic grep recipe.** After a Mac install, the canonical log path is `~/.<brand>/logs/install-*.log`. One pass tells you everything:

```bash
grep -E '^\[create-maxy\] (platform|darwin-hostname-mode|darwin-cloudflare-skip|launchd-plist|init-logging FAILED)=' \
  ~/.<brand>/logs/install-*.log
```

Every successful Mac install contains, in order: `platform=darwin`, `darwin-hostname-mode=…`, `darwin-cloudflare-skip=true`, every brew install/verify line, `launchd-plist=… loaded=true`. Absence of any of these is the failure signal.

## Initial Setup

The Maxy installer handles the full setup. Run it on your Pi:

```bash
npx -y @rubytech/create-maxy
```

This installs all dependencies (Node.js, Neo4j, Cloudflare tunnel, Claude Code), configures the platform, and starts all services.

## What the Installer Does

1. Installs system dependencies
2. Installs Claude Code (the AI engine) and configures it
3. Installs and starts Neo4j (the memory database)
4. Installs and configures the Cloudflare tunnel for remote access
5. Creates your account and sets your PIN
6. Starts the Maxy web server on port 19200
7. Configures systemd so everything restarts automatically if the Pi reboots

## First admin session — install-time defaults

There is no onboarding state machine. At install time the installer writes three defaults into `data/accounts/<accountId>/account.json` (`enabledPlugins` from the brand's default set, `outputStyle: "default"`, `thinkingView: "default"`) and stamps a minimal `agents/admin/SOUL.md`. Diagnostic lines on the Pi:

```
[install-defaults] account-json plugins=<n> outputStyle=default thinkingView=default
[install-defaults] soul-md path=<path>
```

Grep for both in `~/.<brand>/logs/install-*.log`. Absence after a clean install is the failure signal.

## Plugin cache refresh on upgrade

Claude Code loads each platform plugin's skills, commands, and MCP from a per-install snapshot under `~/.<brand>/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`, recorded as `installPath` in `installed_plugins.json` — not from the live tree. The local marketplaces (`maxy-platform`, `maxy-premium-*`) are *directory sources*: an upgrade overwrites their tree in place with no version bump. `claude plugin install` and `claude plugin update` both short-circuit on the unchanged version, so the snapshot would otherwise freeze at first-install time and newly-shipped skills would never register (Task 643).

The installer therefore **resyncs every directory-source plugin on every run**: `claude plugin uninstall` then `claude plugin install`, the only sequence that rebuilds the snapshot from the live tree. Remote marketplaces (the Anthropic ones, GitHub externals) keep version-pinned idempotence. Diagnostic lines on the Pi:

```
[plugin-install] recache <name>@<marketplace>
[plugin-install] audit <name>@<marketplace> live-skills=<n> cache-skills=<m>
[plugin-install] WARN cache-drift <name>@<marketplace> live-skills=<n> cache-skills=<m>
```

Grep `~/.<brand>/logs/install-*.log`. After an upgrade every directory-source plugin shows a `recache` line and an `audit` line whose `live-skills` and `cache-skills` counts are equal. A `WARN cache-drift` line — or unequal counts — means the snapshot did not fully rebuild and the running agent will see stale skills.

The first user-domain write the agent attempts (e.g. recording who the operator is) hits the graph-write gate's `Write blocked (no-admin-user)` or `Write blocked (no-local-business)` error. The agent then asks the persona question, persists the answer through the `business-profile` skill or `profile-update.personFields`, and proceeds. The error itself is the signal — grep `Write blocked` in `~/.<brand>/logs/server.log` to confirm.

Cloudflare, WhatsApp, Telegram, and any other dormant capability surfaces on owner request via the `<dormant-plugins>` sentinel the manager injects per-spawn. Execution is the existing plugin skill (`cloudflare:cloudflare`, etc.) — no banner, no per-step flag.

### Per-spawn system-prompt sentinels

Every PTY spawn injects an `--append-system-prompt` block composed of these sentinel sections in fixed order:

| Sentinel | Source | Behaviour on resolve failure |
|---|---|---|
| `<host>` | brand.json + boot-time LAN resolution | spawn refuses with `host-context-unresolved` |
| `<identity>` | `<accountDir>/agents/<role>/IDENTITY.md` | spawn refuses with `identity-unresolved` |
| `<soul>` | `<accountDir>/agents/<role>/SOUL.md` | spawn refuses with `identity-unresolved` |
| `<about-owner>` | upstream `loadUserProfile` + `formatProfileSummary` (UI process) | sentinel renders the prose body with `NOTHING (operator-data source unavailable: \`<reason>\`)` on line one; spawn proceeds |
| `<specialist-domains>` | `<accountDir>/specialists/agents/*.md` frontmatter (UI process) | sentinel omitted when set is empty |
| `<plugin-manifest>` | enabled plugins' PLUGIN.md frontmatter + skill SKILL.md frontmatter (UI process) | sentinel omitted when set is empty |
| `<dormant-plugins>` | installed-minus-enabled set from `platform/plugins/` and `account.json`, excluding plugins whose PLUGIN.md frontmatter sets `surface: platform` (platform-shell — ships with every install, never opt-in). Absent `surface` field defaults to `feature` (dormant-eligible). | sentinel omitted when set is empty |

Diagnostic line per spawn (`~/.<brand>/logs/server.log`):

```
[pty-spawn] sessionId=<id> appendSystemPromptBytes=<n> identityBytes=<n> soulBytes=<n> aboutOwnerBytes=<n> dormantPluginsBytes=<n> pluginManifestBytes=<n> specialistDomainsBytes=<n> accountDir=<path> role=<admin|public> hostname=<h> lanIPv4=<ip> adminUrl=<url> tunnelUrl=<url|none>
```

The `pty-spawn-start` line additionally carries `hooksResolved=<event1,event2,…>` — the hook event names Claude Code would actually load for that PTY (resolved by walking the same `$CLAUDE_CONFIG_DIR/settings.json` plus the cwd-to-.git path Claude Code itself uses). A Stop hook registered in a settings file outside the loader's scope shows up as a missing event in this list.

Zero `aboutOwnerBytes` on any admin spawn is a regression: the upstream resolver dropped the field entirely. The prose body always carries the unconditional MAXIMISE imperative on line two, so the byte count is positive even on a fresh-account spawn whose line one is `NOTHING`. Zero `dormantPluginsBytes` on a Maxy install with `cloudflare` not enabled is likewise a regression. Zero `pluginManifestBytes` on any account with enabled plugins means the upstream walker failed silently.

The manager also runs a boot-time self-test that renders a fixture compose call against synthetic inputs and refuses to start if any sentinel is missing:

```
[claude-session-manager] startup-self-test system-prompt-sentinels=ok
```

LOUD-FAIL output is `startup-self-test system-prompt-sentinels=fail missing=<tag>` followed by an immediate process exit. Catches IDENTITY-promise-vs-emitter drift at boot rather than at the first real spawn.

### Pre-publish boot smoke

`platform/scripts/smoke-boot-services.sh` runs inside `prepublishOnly` of the installer (`packages/create-maxy-code/`). For every service in its `SERVICES` list it builds a synthetic install dir that mirrors what `seed-neo4j.sh` writes on first boot — real templates copied from `platform/templates/agents/admin/*.md` and `platform/templates/specialists/agents/*.md` into both `<accountDir>/` and `<platformRoot>/templates/`, real plugin `PLUGIN.md` manifests, plus a `.claude/` dir for `CLAUDE_CONFIG_DIR`. The fixture stamps the same env shape the installer's systemd unit writes (dummy `NEO4J_URI`/`NEO4J_PASSWORD` — the manager only checks presence at boot; the live cypher gate runs separately). Then it spawns `node dist/index.js`, waits up to 10 s for the `startup-self-test identity-drift=` line (the last startup self-test the manager logs), SIGTERMs, and fails publish if any `boot-failed reason=` or `^\[.*\] fatal ` line appears. The script asserts each of the three startup self-tests (`specialist-tool-drift=ok`, `system-prompt-sentinels=ok`, `identity-drift=ok`) is present — the real-templates fixture is what makes the assertions non-trivial; Task 438 added it after the 0.1.143 / 0.1.147 / 0.1.155 / 0.1.156 install regressions slipped past an empty fixture. The original Task 099 motivation still holds (module-load regressions tsc and vitest miss — e.g. a stray `require()` in an ESM-typed package).

Cypher schema gate (Task 438): the same script applies `platform/neo4j/schema.cypher` to the maintainer's local Neo4j via `cypher-shell`, using `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` env (same shape `seed-neo4j.sh` reads — falls back to `platform/config/.neo4j-password` for the password). The dev's database is the test surface; schema commands use `IF NOT EXISTS` / `IF EXISTS` so re-apply is idempotent. Catches Neo4j 4 → 5 syntax drift (e.g. 0.1.151's `DROP FULLTEXT INDEX`) at publish time, not on a real install. Absence of `cypher-shell` on PATH or unset `NEO4J_URI` fails the gate loudly — the same toolchain the installer requires on every device.

Companion lint: `platform/scripts/check-no-esm-require.mjs` rejects `require(` calls in any `.ts/.tsx/.js` file inside a package with `"type": "module"`, also wired into `prepublishOnly`. Allowlist lives at the top of the script.

## Service Management

Maxy runs via systemd and starts automatically on boot. You don't need to start it manually. To check if it's running, ask Maxy "Check system status."

If you need to restart the service manually (rare), ask Maxy to do it for you.

## Browsing the brand filesystem on your LAN (SMB)

Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only binding posture.

## Remote Access via Cloudflare

Maxy uses a Cloudflare tunnel to make your local Pi accessible from anywhere without opening router ports. The tunnel is configured during setup and runs as a background service.

Setting it up: say "Set up remote access." Maxy walks you through signing into Cloudflare, picking your domain (if you have more than one on your Cloudflare account), and then shows a form where you pick a short name that becomes your admin address. For example, entering `joel` gives you `https://joel.your-domain.com` for admin access. You can also pick a separate address for the public chat, or leave it blank to skip public access. The form only accepts valid address characters (lowercase letters, numbers, hyphens) — Maxy never asks you to type your full URL in chat.

Your admin URL looks like: `https://joel.maxy.chat` (the short name is whatever you picked in the form).

To check the tunnel status: ask Maxy "Check Cloudflare tunnel status."

To restart the tunnel: ask Maxy "Restart the Cloudflare tunnel."

## Checking Service Status

Ask Maxy: "Check system status."

The `system-status` tool reports the health of all services: Neo4j, the web server, the Cloudflare tunnel, and all active MCP servers.

## If Maxy Won't Start

From the Pi directly:

```bash
sudo systemctl status maxy
sudo journalctl -u maxy -n 50
```

The logs will show which service failed to start and why. Common causes:

- **Neo4j not started** — run `sudo systemctl start neo4j` and retry
- **Port 19200 already in use** — check for another process: `lsof -i:19200`
- **Claude OAuth expired** — the next admin session will prompt you to re-authenticate
- **NEO4J_URI guard throws** — the admin agent probes device reality at boot and fails closed on three shapes (earlier platform fixessucceeding earlier platform fixes):
    - `no Neo4j listening on [ports]` — nothing is bound; start `neo4j.service` or `neo4j-<brand>.service`, or edit `NEO4J_URI` to a port a Neo4j is actually running on.
    - `port:X not listening; only:Y is live` — single-brand device where `.env` names a port the local Neo4j isn't bound to; edit `NEO4J_URI` in `~/{configDir}/.env` to match the live port (shown in the `[neo4j-probe] listening=[…]` log line).
    - `port:X disagrees with brand.json neo4jPort:Y` — co-tenant device (2+ Neo4js listening) where `.env` names the other brand's port; edit `NEO4J_URI` to match `brand.neo4jPort`, or correct `neo4jPort` in `brand.json` and reinstall. Preserves the earlier platform fixes orphan-write protection on multi-brand devices.

## Systemd units on each device

Each installed brand runs two per-brand `--user` systemd units (earlier platform fixes + — unit filenames are prefixed with the brand's `hostname` so two brands on the same device never share a unit file):

- `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. An earlier fix: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
- `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so the log stream survives the brand service's restart window. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.

Upgrade and Cloudflare setup run as detached actions: `systemd-run --user` transient units per invocation with stdout+stderr persisted to `~/.maxy/logs/actions/<actionId>.log` and streamed to the UI via SSE. No boot-time service file exists for these.

If an action looks stuck, read `~/.maxy/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record.

## Linux laptops: snap-confined Chromium replacement

On Ubuntu 24.04 (Noble) the system Chromium binary at `/usr/bin/chromium` is a symlink into the snap. Snap's AppArmor profile denies writes to hidden directories under your home folder, so the per-brand Chromium profile at `~/.{brand}/chromium-profile/` is unwritable and the VNC browser never starts. Pi installs (Debian Bookworm) are unaffected because Bookworm ships a real `.deb` chromium.

The installer detects this case during system-dependency setup and replaces the snap binary with Google Chrome stable, installed from Google's signed apt repo. The chosen binary's absolute path is recorded in `<INSTALL_DIR>/platform/config/chromium-binary.path` and read by the two call sites that launch Chromium — the VNC service and the in-page Chromium wrapper. (The `browser` plugin never launches its own Chromium; it attaches over CDP to the VNC Chromium that `vnc.sh` starts with `--remote-debugging-port`, so it doesn't consult this path.) If you ever see `chromium-binary.path missing` or `Chromium ... resolves to ... which is snap-confined` in `~/.{brand}/logs/vnc-boot.log`, re-run the installer to re-provision.

The post-install acceptance gate at `platform/scripts/test-laptop-vnc-boot.sh` runs four checks: the configured Chromium realpath is non-snap, the path is absolute and executable, the per-brand CDP port returns Chromium version JSON, and the VNC boot log ends with `VNC + browser stack running` with no preceding `Chromium failed to start`. The gate runs automatically at the end of every install on Linux; manual invocation is `MAXY_PLATFORM_ROOT=<install-dir>/platform <install-dir>/platform/scripts/test-laptop-vnc-boot.sh`.

A separate operator-side harness at `platform/scripts/installer-device-verify.sh <published-version>` runs after every npm publish to confirm the installer reaches a terminal-success marker on each device in the operator's manifest. Two markers are accepted because the installer's CDP probe behaves differently per `DISPLAY_MODE`: `Browser automation ready (CDP connected)` on Pi (virtual display, persistent Chromium) and `[cdp-check] skipped reason=native-display` on laptop (native display, on-demand Chromium). Either is a pass. The harness is operator-only — end users do not run it.

## Plugin registration at install time

The installer registers Claude Code plugins on the device as the last step before the brand service starts. After registration, `claude plugin list` on the Pi shows every Maxy platform plugin shipped by the brand, every premium sub-plugin shipped by the brand, and any external plugins the brand declares (e.g. Telegram, Discord, iMessage from `claude-plugins-official`). Spawned `claude` sessions inherit those plugins from `~/.claude/` — the session manager passes no `--mcp-config` argv.

**Where the manifests come from.** The Maxy plugin source tree uses `PLUGIN.md` (YAML frontmatter) for plugin metadata, not Claude Code's native `.claude-plugin/plugin.json`. At bundle time, `scripts/generate-plugin-manifests.mjs` walks the payload and synthesises a Claude-Code-native `plugin.json` per plugin plus a `marketplace.json` at each tree root. The generator runs in `packages/create-maxy-code/scripts/bundle.js` after platform + premium plugins are copied into the payload, so the deployed install directory carries:

- `<INSTALL_DIR>/platform/plugins/<name>/.claude-plugin/plugin.json` per platform plugin
- `<INSTALL_DIR>/platform/plugins/.claude-plugin/marketplace.json` (marketplace `maxy-platform`)
- `<INSTALL_DIR>/premium-plugins/real-agent/plugins/<sub>/.claude-plugin/plugin.json` per sub-plugin
- `<INSTALL_DIR>/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json` (`maxy-premium-real-agent`)
- `<INSTALL_DIR>/premium-plugins/{teaching,writer-craft}/.claude-plugin/plugin.json` for bundle-root plugins
- `<INSTALL_DIR>/premium-plugins/.claude-plugin/marketplace.json` (`maxy-premium`)

Generator schema:

| Field | Source | Notes |
|---|---|---|
| `name` | directory name (or `PLUGIN.md#name`) | Used as `<name>` in `plugin install` |
| `description` | `PLUGIN.md` frontmatter `description` | Falls back to "{name} plugin" if absent |
| `version` | `"0.1.0"` | Single version across all generated manifests |
| `author` | `{ "name": "Rubytech LLC" }` | Object form required by Claude Code's validator |
| `mcpServers["<name>"]` | only when `mcp/dist/index.js` exists | `{ "type": "stdio", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/mcp/dist/index.js"] }` |

Skills, agents, hooks, and commands directories at the plugin root are auto-discovered by Claude Code — no explicit field needed.

**Install flow** (`registerLocalAndExternalPlugins()` in `packages/create-maxy-code/src/index.ts`):

1. Discover every `.claude-plugin/marketplace.json` under the install directory.
2. For each one not already in `claude plugin marketplace list`, run `claude plugin marketplace add <dir>`. Pre-existing entries log `[plugin-marketplace] added <name> idempotent=true`.
3. Snapshot `claude plugin list` once.
4. Build the desired plugin set = (every local marketplace's plugin entries) + (`brand.json#externalPlugins`).
5. For each desired plugin not in the snapshot, run `claude plugin install <name>@<marketplace> --scope user`. Already-installed plugins log `idempotent=true`. Failures log `[plugin-install] ERROR <name>@<src> exit=<n> stderr=<short>` but do not abort the installer — one plugin failing must not block the rest.

External channel plugins (telegram, discord) are installed but not configured at install time. The operator pairs a channel by running `/<name>:configure <token>` in a real session; no token is read from the install environment.

**Brand declaration** — `brands/<brand>/brand.json#externalPlugins`:

```jsonc
"externalPlugins": [
  { "name": "telegram", "marketplace": "claude-plugins-official", "channelPlugin": true },
  { "name": "discord",  "marketplace": "claude-plugins-official", "channelPlugin": true },
  { "name": "imessage", "marketplace": "claude-plugins-official", "channelPlugin": true }
]
```

`channelPlugin: true` signals the session manager to include the entry in the spawn-time `--channels plugin:<name>@<marketplace>` argv. The session manager's `/public-spawn` and `/resume` HTTP routes accept an optional `channels: string[]` body field that maps directly to those argv flags. When the field is absent or empty, the spawn argv is byte-identical to today's `['--verbose', '--remote-control']` shape.

**Diagnostic path** — `grep "\[plugin-install\]" ~/.<brand>/logs/install-*.log | tail -50`; compare row count against `cat brand.json | jq '.externalPlugins | length'` plus the on-disk plugin count under `<INSTALL_DIR>/platform/plugins/` and `<INSTALL_DIR>/premium-plugins/`.

**Premium MCP dependency install** — Premium-plugin MCP servers ship `dist/` + `package.json` in the bundle but not `node_modules` (npm pack strips them, same as `server/`). `buildPlatform()` discovers every `<INSTALL_DIR>/premium-plugins/<bundle>/plugins/<plugin>/mcp/package.json` and runs `npm install --omit=dev` there, wiping any prior `node_modules` first. The summary log line `[install] premium-mcp-install dirs=<n>` is emitted before the loop runs, so `dirs=0` is itself a regression signal when a brand ships premium plugins.

## Running multiple brands on one device

A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.

- **Separate:** each brand has its own install folder (`~/maxy/`, `~/realagent/`), its own config folder (`~/.maxy/`, `~/.realagent/`), its own web port, its own Cloudflare tunnel state, its own edge systemd unit (`maxy-edge.service` vs `realagent-edge.service`), and by default its own Neo4j database (Maxy on bolt port 7687, Real Agent on 7688). Action runner units are transient and per-invocation, not per-brand, so no naming conflict is possible.
- **Brand-isolated Neo4j:** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
- **Peer-aware system-unit guard:** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the earlier platform fixes reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged. The dedicated unit exports `NEO4J_HOME=<per-brand-data-dir>` alongside `NEO4J_CONF`, so `server.directories.run`, `server.directories.plugins`, and `server.directories.import` resolve per-brand — no collision with `/var/lib/neo4j/run/neo4j.pid`. The conf sed-overrides, mkdir-p, chown, and unit-write are idempotent and re-run on every install, so a host whose prior install left a broken unit recovers on retry.
- **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.

To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation:

```bash
# Already running Maxy on port 20000. Install Real Agent on a different port:
npx -y @rubytech/create-realagent --port 19500
```

Uninstalling one brand removes only that brand's state when the other brand is present: this brand's install folder, config folder, its own Neo4j data (if it runs a dedicated instance; shared data is left alone), its Cloudflare tunnel, and its systemd service. Shared binaries (Ollama, `cloudflared`), apt packages, and device-wide caches (`~/.claude`, `~/.ollama`) are left in place because the other brand is still using them. When no other brand is present, the uninstaller performs a full device decommission as before.

## Version provenance

The version that the burger-menu badge displays is the same string the installer wrote to disk. Five links in the chain, each with its own log signal so a wrong badge can be traced to the broken hop in one grep:

1. `packages/create-maxy-code/package.json` — `version` field. Bumped manually by the operator (`bin/publish-installers.sh:7`) before each publish. Single source of truth for every brand at a given release; the bundle script propagates one bump to every brand.
2. `packages/create-maxy-code/src/index.ts:3828-3830` — the installer reads its own `package.json` into the `PKG_VERSION` constant at start-up.
3. `packages/create-maxy-code/src/index.ts:2018` — the installer writes `PKG_VERSION` to `<configDir>/.${BRAND.hostname}-version` and logs `[install] version-marker written path=<absolute> version=<semver>`. Absence of that line in install.log means no marker was written for this run.
4. `platform/ui/server/routes/admin/version.ts` — the `/api/admin/version` route resolves `config/brand.json` per request, reads `config/.${brandHostname}-version`, and logs `[admin/version] outcome=<...> installed=<...> versionFile=<resolved-path> npmPackage=<resolved-name>`. On any brand.json defect (file missing, parse failure, or missing `hostname` / `npm.packageName` field) it emits one `[admin/version] brand-config-fallback reason=<file-missing|parse-failed|field-missing> field=<hostname|npm.packageName> using=<default>` per (reason, field) pair per process, then falls back to the defaults (`maxy` / `@rubytech/create-maxy`). No fallback line in the process log = brand.json resolved cleanly.
5. `platform/ui/app/components/header/HeaderMenu.tsx` — the menu renders `v${versionInfo.installed}` from the route's JSON response.

Diagnostic when the menu shows the wrong version:

```bash
# 1. Marker file actually on disk?
ssh <device> 'cat ~/.<brand>-code/install/platform/config/.<brandHostname>-version'

# 2. What did the route resolve to?
ssh <device> 'grep "\[admin/version\]" ~/.<brand>-code/logs/server.log | tail -5'

# 3. Any silent brand.json fallback?
ssh <device> 'grep "brand-config-fallback" ~/.<brand>-code/logs/server.log'
```

Empty output from step 3 = brand.json resolved cleanly and the badge reflects the file from step 1.

## Upgrading

To upgrade Maxy to the latest version, ask Maxy: "Upgrade Maxy." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.

The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.

### Automatic upgrade alert

Maxy checks for new releases on every admin session start — whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.

The upgrade runs inside a live terminal embedded in the Software Update window — you see each installation step stream as it happens, and any password prompts from `sudo` appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.

The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.

---
# Samba Share
Source: https://docs.getmaxy.com/samba.md

# Samba Share

Every Maxy install provisions a per-brand SMB network share so you can read and write the brand's install folder from Finder, File Explorer, the Files app on iOS, or any SMB-capable client on Android or Linux. No client install required — every modern OS speaks SMB natively.

The share lives next to the rest of the brand. On a device that runs more than one brand, each brand gets its own stanza, its own credentials, and its own lifecycle. Tearing one brand down never touches another brand's share.

## What gets provisioned

The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:

1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
2. **conf** — writes `/etc/samba/smb.conf` with a LAN-only `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`.
3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
4. **units** — `systemctl enable --now smbd nmbd` so the share is reachable as soon as the install finishes.

macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.

## Share path

| Client | Address |
|---|---|
| macOS Finder | `smb://<hostname>.local` then pick the `<brand>` share |
| Windows Explorer | `\\<hostname>.local\<brand>` |
| Linux (`mount.cifs`, Nautilus, KDE) | `//<hostname>.local/<brand>` |
| iOS Files | `smb://<hostname>.local` |
| Android (Solid Explorer, CX File Explorer) | Host `<hostname>.local`, share `<brand>` |

`<hostname>` is whatever the installer printed at the end of `npx @rubytech/create-<brand>-code install` — usually the brand name on a fresh Pi (`maxy-code.local`, `realagent-code.local`). `<brand>` is the same string — it is also the install folder name under the install owner's home.

If `<hostname>.local` does not resolve from your client (some networks do not route mDNS), fall back to the LAN IP: `smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows.

## Credentials

- **Username** — the Unix user that owns the install on the device. On a Pi or Hetzner box this is `admin`; on a self-hosted Linux laptop it is whatever Linux user ran the installer (for example `neo`). The installer persists this value to `~/.<brand>/.install-owner` so every later read uses the same identity the installer wrote.
- **Password** — your current Maxy PIN. The same PIN that unlocks the admin UI unlocks the SMB share.

Both halves are required. SMB never accepts a guest connection; `map to guest = bad user` is set in the global stanza.

## PIN rotation

There is no separate "SMB password." When you set or rotate the PIN in the admin UI, the platform's `set-pin` route runs `sudo -n smbpasswd -a -s <install-owner>` inline with the new PIN, behind a `NOPASSWD` sudoers grant written at install time and scoped to that exact command.

So:

- On a fresh Pi or Hetzner box, the share is reachable as soon as you set the first PIN. Before that point the `smbpasswd` entry does not exist and the mount fails with a logon error — that is expected.
- Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
- If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.

## LAN-only binding

The `[global]` section binds smbd to loopback plus one LAN interface:

```
interfaces = lo <lan>
bind interfaces only = yes
```

`<lan>` is whichever non-loopback interface has an IPv4 address — `wlan0` preferred, then `eth0`, then the first other interface with an address. If the device has no LAN interface at all, the installer refuses to provision and exits — there is nothing safe to bind to.

This is the structural guarantee that SMB never leaves the LAN, even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB. **On a Hetzner box the share is therefore not reachable from the public internet** — operators reach it by `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445`, or by running the Hetzner box on a private network that the operator's machine also joins.

## Peer-brand lifecycle

A device that hosts more than one brand carries one stanza per brand in `/etc/samba/smb.conf`. The provisioner is idempotent and peer-safe:

- **Install a second brand** — the new brand's stanza is appended next to the existing one. The shared `[global]` section is rewritten to keep the LAN-only directives current but is otherwise unchanged. Peer-brand stanzas are preserved byte-for-byte.
- **Re-run the installer on an existing brand** — the brand's own stanza is replaced in place. Peer stanzas are not touched.
- **Uninstall one brand** — only that brand's stanza is stripped. `smbd` is then `reload`ed so the brand share disappears from the running config without dropping connections to peer shares.
- **Uninstall the last brand** — after the stanza is removed, the uninstaller checks `hasAnyBrandStanza()` and, if false, stops and disables `smbd`/`nmbd`, runs `smbpasswd -x <install-owner>` to drop the smbpasswd entry, and `apt-purge samba`. If any peer stanza remains, the units stay running and the package stays installed — the uninstaller logs `Leaving smbd/nmbd + samba package in place — other brand stanza remains`.

The brand-stanza name is the only identifier the uninstaller matches on, so two brands with different `BRAND.hostname` values cannot collide.

## Troubleshooting

- **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
- **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd nmbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
- **Hetzner share not reachable from outside the box.** This is by design — see "LAN-only binding" above. Use SSH port forwarding.

Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.

---
# Troubleshooting
Source: https://docs.getmaxy.com/troubleshooting.md

# Troubleshooting

## Stream-log file for a fresh session is absent or empty

**Symptom:** Operator opens a new admin session, sends one turn, sees the agent reply, then `logs-read sessionKey=<…>` returns `file-not-found` or zero bytes.

**Invariant:** For every new session, the stream-log file exists on disk iff at least one token byte has been emitted, and contains the token bytes from the moment the first token returns to the operator. The single-writer mandate (2026-05-14) mechanically enforces both halves of the contract: the single writer module at `platform/ui/app/lib/claude-agent/stream-log-writer.ts` opens the file lazily on `streamLog.writeToken` (the SDK first-byte site at [`stream-parser.ts:296`](../../../ui/app/lib/claude-agent/stream-parser.ts#L296)), and the build gate `platform/ui/scripts/check-stream-log-writer.mjs` rejects every external `appendFileSync`/`createWriteStream` against the `claude-agent-stream-*` pattern at CI time. The first-token invariant is bound by `platform/scripts/__tests__/first-token-creates-stream-log.test.sh`: one operator turn, one token, `claude-agent-stream-<sessionKey>.log` exists and contains the token bytes — pass iff file present and bytes present. The hourly adherence runner `platform/scripts/log-adherence-check.sh` extends the device-side check with a duplicate-basename diagnostic (`dup-basenames=N` in the `[log-tee] adherence-check` line); `dup>0` is a P0 page meaning the writer collapse regressed.

**Diagnose if it ever recurs:** run `bash platform/scripts/__tests__/first-token-creates-stream-log.test.sh` from the install. Pass = invariant holds; any other exit = the writer-side existence contract is broken and one `[log-tee] missing-on-resolve sessionKey=<8> surface=<…>` line on `server.log` is the operator-visible signal (P0). For the duplicate-file class specifically (the 2026-05-14 recurrence trigger), `bash platform/scripts/log-adherence-check.sh` returns non-zero whenever any sessionKey has more than one `claude-agent-stream-<sk>.log` across account dirs.

## Retrieving evidence from an rc-spawn session

rc-spawn sessions (those started via the sidebar or the `claude rc --spawn` daemon) do not write a per-account stream log under `data/accounts/<id>/logs/`. Their evidence is the Claude Code JSONL transcript in the configDir:

```
<CLAUDE_CONFIG_DIR>/projects/<slug>/<uuid>.jsonl                      # parent session
<…>/projects/<slug>/<uuid>.meta.json                                  # bridgeIds persistent map
<…>/projects/<slug>/<uuid>/subagents/agent-<hex>.jsonl               # each subagent
<…>/projects/<slug>/<uuid>/subagents/agent-<hex>.meta.json           # {"agentType",…}
```

**Retrieve a session's merged timeline:** `logs-read.sh <key>` with a bare key (no second argument) maps the key to the local `<uuid>` and prints one timestamp-ordered timeline merging the parent transcript with every subagent transcript. The key is resolved in order: a matching `<uuid>.jsonl` on disk; a `sessions/<pid>.json` whose `bridgeSessionId` matches; a `<uuid>.meta.json` whose `bridgeIds` carries the suffix (persistent — survives PID-file cleanup on clean exit); and finally a content scan of the top-level transcripts as last resort. Any accepted key form works: the `claude.ai` `session_<id>`, its bare suffix, or the `<uuid>` (or a unique uuid prefix).

Every subagent `is_error` tool_result is flagged inline as `‼ SUBAGENT ERROR` with the agent type, the failing tool, and the error text. The parent session's own tool errors appear as `‼ tool error`. The two are never conflated.

**Audit all silently-failed subagents:** `logs-read.sh --scan-subagent-errors [N]` walks every `subagents/agent-*.jsonl` under the configDir and lists each one carrying an `is_error` result — agent type, parent session, failing tool, error text. Optional `N` limits the scan to the `N` most-recently-modified transcripts. Use this when a delivery failure was reported but no reproduction is available.

**Quick recipes:**

```bash
# A session's merged parent+subagent timeline (subagent errors flagged inline)
~/maxy-code/platform/scripts/logs-read.sh session_<id>

# Standing audit: every subagent transcript that failed silently
~/maxy-code/platform/scripts/logs-read.sh --scan-subagent-errors

# Limit audit to the 50 most-recent transcripts
~/maxy-code/platform/scripts/logs-read.sh --scan-subagent-errors 50
```

Note: passing an explicit second argument (e.g. `logs-read.sh <key> agent-stream`) still reads the legacy per-account stream log — the bare-key JSONL path is the default when no type is given.

## A JavaScript-rendered page comes back empty from WebFetch or `url-get`

**Symptom:** A page that needs JavaScript to show its content returns empty or a shell document from `WebFetch` (summary) or `url-get` (verbatim, server-rendered).

**Resolution:** Use the `browser` core plugin's `browser-render` tool. It renders the page in the device's per-brand Chromium over the Chrome DevTools Protocol (the same browser the VNC viewer shows) and returns the rendered HTML plus visible text. It attaches to the already-running Chromium on `127.0.0.1:${CDP_PORT}` — nothing is downloaded or installed mid-session.

**Diagnose if it ever recurs:** grep the per-conversation stream log for `[browser-render]`. `rendered=true domBytes=<n>` is the healthy signal. `rendered=false outcome=cdp-unreachable` means no Chromium is listening on the brand's CDP port — confirm with `curl 127.0.0.1:<cdpPort>/json/version`. Other outcomes (`navigate-failed`, `load-timeout`, `evaluate-failed`) name the failed CDP step.

## First user-domain write rejected by `[graph-write-gate] reject reason=no-admin-user`

**Symptom:** Admin chat reports "couldn't save that — set up your business profile first" or `[graph-write-gate] reject reason=no-admin-user` appears in `server.log` on the operator's first non-bootstrap write (a website, service, opening hours, etc.). Reproduces on Minimal-onboarded installs from before the seed-stamping fix shipped.

**Diagnose:** Tail the gate reject and self-heal lines together:

```
grep -E "adminuser-self-heal|graph-write-gate.*reject" <server.log>
```

- `[adminuser-self-heal] healed=1 …` followed by no `[graph-write-gate] reject` lines on subsequent writes — heal fired, the gate is now passing. Operator can retry.
- `[adminuser-self-heal] healed=0 …` + `[graph-write-gate] reject … subReason=admin-user-no-accountid` — heal couldn't reach the broken node. Most likely cause: the env-side `ACCOUNT_ID` doesn't match any `:AdminUser.userId`. Cross-check `users.json[0].userId` against `MATCH (au:AdminUser) RETURN au.userId, au.accountId` — if the userId mismatches, the post-Task-904 `[admin-invariant]` line in the same log will show `direction=users-without-account` and the repair is to align the stores per `.docs/agents.md` § "Three-store admin auth invariant", not to retry the heal.
- `[graph-write-gate] reject … subReason=no-admin-user-node` — the graph has no `:AdminUser` at all. Re-run the seed (`platform/scripts/seed-neo4j.sh`) under the install's env vars; the boot self-heal won't help because there's nothing to heal.

The `subReason=admin-user-no-accountid` path should be impossible on any install whose admin server has booted at least once after the boot self-heal shipped — if it fires, the diagnostic recipe is the cross-check above, not "rerun the heal."

## Fresh install opens to "Set your remote password" on the LAN URL

**Symptom:** On a brand-new device, the LAN URL printed by `create-maxy` (e.g. `http://maxy.local:19200`) opens to a remote-password setup page instead of admin onboarding. This was a Task-647-era regression and should not occur on any install built.

**Diagnose:** On the Pi, grep the UI server log for the gate's disambiguation fields:

```
tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
```

- `resolvedKind=lan` on a `login required` or `not configured` line means the classifier sees the request as local — if the browser is still on the remote-auth page, something cached the older page before the fix shipped (hard-refresh the tab).
- `resolvedKind=external` means the request chain presents as remote (routable IP in the first `x-forwarded-for` hop). On a LAN-only browser this points to a proxy or VPN rewriting headers between the browser and the Pi.
- `resolvedKind=unknown` is a defect — the classifier could not identify the TCP peer. Capture the log line and file it; do not work around it.

**Fix:** If all three fields confirm the LAN shape and the gate still refuses, upgrade the platform (`Software Update` from admin chat) to pick up the Task-679 classifier.

---

## Remote sign-in is rejected with "Remote access requires TLS"

**Symptom:** Posting the remote-auth password returns a plain-text `400 Remote access requires TLS` response instead of completing sign-in.

**What this means:** The login endpoint will only issue a session cookie when the request arrived over HTTPS (via the Cloudflare tunnel). Browsers silently drop `Set-Cookie: Secure` on plain-HTTP responses, so minting a cookie there would produce a dead-end redirect. An earlier fix replaced that silent failure with this loud one.

**Fix:** Reach the admin surface through the tunnel hostname (e.g. `https://admin.<your-domain>`), not an IP or plain-HTTP URL. If you need LAN access, use the LAN URL (`http://<hostname>.local:<port>`) — LAN never hits the remote-auth endpoint.

---

## Agent Not Responding

**Symptom:** You send a message and nothing comes back, or the response never arrives.

**Check:**
1. Ask Maxy: "Check system status" — the `system-status` tool will report whether all services are running
2. Check the platform logs: ask Maxy "Show me the recent logs"
3. If the admin agent itself won't start: restart the platform (see below)

**Common causes:**
- Claude API connectivity issue — check your Claude OAuth connection is still valid
- Platform process has stopped — restart it
- Network issue if accessing remotely — check your Cloudflare tunnel is running

**If the chat shows a single `[agent-loop-stop] same error twice — aborting` line and stops:** Maxy hit the same structured tool failure twice in a row inside one turn (e.g. a permission gate refused the same write twice, or two `Read` calls hit the same missing file). The runtime aborted the turn after the second occurrence to save tokens instead of running until the SDK turn budget exhausted. The blocker text names the tool and the first line of the error. Resolve the underlying cause (re-run the named skill, fix the missing prerequisite, etc.) and tap "Continue" — the next turn truly resumes the prior SDK session via the synthetic-tool-result contract, so Maxy picks up where it aborted instead of cold-querying its own session list. To see the diagnostic, ask Maxy: "Show me the most recent stall-recovery log line." Greppable post-deploy invariants: `[agent-loop-stop] reason=identical-tool-failure tool=<name> errorSignature=<sha8> toolInputDigest=<sha8>` followed by `[stall-recovery] kind=agent_loop_stop … handoff=resume-first` and on the next turn `[stall-resume] consumed kind=agent_loop_stop toolUseId=<8> priorSessionId=<8>`. The fallback path (when the SDK session id was lost) emits `handoff=metadata-only` + `[recovery-handoff] generated/consumed reason=agent-loop-stop` and the chat button reads "Start over" instead of "Continue". A `[recovery-handoff] WARN missing-on-cold-create` line means the fallback briefing wasn't persisted — surface to support.

**If a background task goes silent and the chat shows "A background task went silent — K of M completed":** Maxy's subagent stopped emitting progress for over 2 minutes. Tap "Continue" — the next turn resumes the prior session and reads a synthetic tool_result describing what completed before the pause, so the agent re-plans without losing the work it had done. Most stalls are upstream API latency rather than the subagent's approach failing — the resume-first path treats both correctly. Greppable post-deploy invariants: `[stall-recovery] kind=subagent_stalled … completed=<K>/? handoff=resume-first` followed by `[stall-resume] consumed kind=subagent_stalled toolUseId=<8>` on the next turn. If the button reads "Start over" instead, the parent's pending tool_use_id was not captured — the fallback path took over; the prior conversation is preserved as a `<recovery-context>` block in the cold-started session.

**Agent searches the filesystem after uploading a zip.** If you uploaded a zip and the agent burns several turns running `find` / `Glob` instead of unzipping, that is the symptom of the recovery-retry attachment-context regression (now closed by the recovery context preservation contract in `.docs/agents.md`). Greppable confirmation is the `[context-overflow-recovery] retry … attachmentsCarried=<n>` line in the conversation stream log. If you see `[context-overflow-recovery] WARN attachment-context-lost`, the regression has returned — surface to support.

**Turn budget exhausted with a horizontal rule separating two assistant turns.** When Maxy reaches its turn budget and the doubled retry also runs out, the chat now shows a one-paragraph assistant message that opens with `error_max_turns turns=A→B` (initial budget → final budget) followed by the recovery copy: "I reached my turn budget of N before I could finish this request. Try sending a smaller or more focused request, or ask me to use higher effort." That message is persisted to the graph, so the next page-refresh still shows it. The thin horizontal rule labelled "Session restored after timeout." that appears above your following turn signals that the prior turn forced a cold SDK-session restart inside the same conversation (pool eviction) — the agent's response after the rule is from a fresh SDK session even though the conversation thread is unchanged. Greppable post-deploy invariants: `[context-overflow-recovery] exhausted cause=max-turns-interrupted` count equals `[admin-persist] writer=persistMessageExhaust outcome=ok` count for the same sessionId window, and one `[session-store] storeAgentSessionId` line marks the cold-restart that drove the on-screen rule.


**A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-session stream log (`logs/claude-agent-stream-<sessionKey>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
**Wrong Claude account answering on a multi-brand device.** On a host running both Maxy and Real Agent, each brand's admin agent reads its own `~/${brand.configDir}/.claude/.credentials.json`; there is no longer a shared `~/.claude/` thrashing them against one another. If a brand reports auth failures or appears to be operating against the wrong subscription, check three things:
1. `grep "\[claude-auth\] init" ~/.${brand}/logs/server.log | tail -1` — the resolved path must end with `~/.${brand}/.claude/.credentials.json`. If a `[claude-auth] WARN cross-brand-path-detected` line is present, the runtime is still pointing at `~/.claude/`; the brand main service did not pick up the `Environment=CLAUDE_CONFIG_DIR=` setting (re-run the brand installer to refresh the unit file).
2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
3. `grep "\[install\] claude-creds pickup" ~/.${brand}/logs/install-*.log` — fires once on the first post-Task-923 install of any brand and moves the legacy `~/.claude/.credentials.json` into that brand's path. Subsequent brands install with no credentials and require a fresh `claude /login` inside that brand's chat (which writes to the brand-scoped path because the systemd unit env is in scope).

**All sessions on the brand stopped responding after a token expiry.** Symptom on the operator side: every spawn dies at `pid-file-timeout` and the dashboard health probe reports auth dead. Diagnose the OAuth refresh path before anything else:

1. `tail -n 300 ~/.${brand}/logs/server.log | grep -E 'auth-refresh|auth-health|invalid_grant'` — `op=lock-acquired` proves the cross-process lock is in play (Task 576). `op=skipped-fresh` means a sibling process (the admin server or a `claude` binary) already rotated the tokens during the lock wait — expected, healthy. `op=renewed expiresAt=…` is the only line that means a network refresh actually ran.
2. `outcome=fail-token` or `invalid_grant` lines mean Anthropic rejected the refresh token itself (revoked or expired beyond the rotation window). The brand needs a fresh `claude /login`. Pre-576 the most common cause was the admin server and a spawned `claude` racing to rotate the same single-use refresh token; that race is now serialised by the file lock at `~/.${brand}/.claude/.credentials.json.lock` and a re-read after the lock skips redundant refreshes.
3. `grep '\[auth-health\]' ~/.${brand}/logs/server.log | tail -n 5` — the heartbeat fires every five minutes. `status=dead expiresIn=...` means the refresh token is gone; only a re-login fixes it. `status=ok` heartbeats with no spawns in between mean the credentials file is healthy and the failure lives elsewhere.
4. The spawn-failure surface now carries `reason=auth-refresh-failed` (with `authStatus` in the JSON body) instead of generic `pid-file-timeout` whenever the credentials file is in `dead` or `expired` state at the moment of failure — visible in `grep '\[spawn-failed\]'` on server.log.

---

## Memory Not Working

**Symptom:** Maxy doesn't remember things you've told it, or search returns nothing.

**Check:**
1. Ask Maxy: "Check the Neo4j connection"
2. Ask Maxy: "Search memory for [something you know was stored]"

**Common causes:**
- Neo4j service stopped — restart the platform, which restarts Neo4j
- Memory index is stale — ask Maxy: "Reindex memory"

---

## Telegram Bot Not Receiving Messages

**Symptom:** You send a message to the bot and nothing happens.

**Check:**
1. Confirm the bot token is correct: ask Maxy "What Telegram bot token is configured?"
2. Verify the bot is running: send `/start` to the bot in Telegram
3. Check the MCP server logs: ask Maxy "Show Telegram plugin logs"

**Common causes:**
- Bot token changed (if you regenerated it in BotFather) — update it by telling Maxy "Update my Telegram bot token"
- Webhook not connected — restart the platform

---

## Plugin Errors

**Symptom:** A tool fails with an error, or a plugin says it can't connect.

**Check:**
1. Ask Maxy: "Show me recent errors"
2. Ask Maxy: "Restart the [plugin name] plugin"

**Common causes:**
- Missing environment variable (API key, token) — the error message will name it; ask Maxy to help configure it
- MCP server crashed — restarting the platform restarts all MCP servers

---

## Cannot Mount the SMB Share

**Symptom:** Mounting `smb://<hostname>.local` (or `\\<hostname>.local\<brand>`) fails with a "logon failure" or the share does not appear in your network browser.

**Check:**
1. Confirm you have set a PIN in the admin UI at least once. On a fresh Pi or Hetzner box the `smbpasswd` entry does not exist until the first set-pin runs — mounts before that point always fail.
2. Use the install owner as the username (`admin` on a Pi or Hetzner box; the Linux user that ran the installer on a self-hosted laptop) and the current Maxy PIN as the password. The SMB password is not stored separately — it is the PIN.
3. If `<hostname>.local` does not resolve from your client, mount by LAN IP instead (`smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows).
4. Rotate the PIN in the admin UI. That re-triggers the `smbpasswd` sync on the device. If the resync log line reads `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing`, restore `~/.<brand>/.install-owner` from the installer log.

See [Samba Share](./samba.md) for the full credential model and per-OS mount syntax.

---

## Restarting the Platform

From the admin interface, ask Maxy: "Restart the platform."

If Maxy itself isn't responding (the page loads but the agent won't connect), try refreshing the browser. If the page itself won't load, the platform process may have stopped — power-cycle the Raspberry Pi by unplugging and reconnecting power, then wait a minute for services to restart automatically.

---

## Checking Logs

Ask Maxy: "Show me the logs" or "Show errors from the last hour."

For specific plugin logs: "Show Telegram logs" or "Show contacts plugin logs."

Maxy has access to all platform logs and can filter them for you.

---

## Cloudflare Tunnel Down (Remote Access Broken)

**Symptom:** You can reach Maxy on your local network but not via your public domain.

**Check:** Ask Maxy "Check the Cloudflare tunnel status."

**Fix:** Ask Maxy "Restart the Cloudflare tunnel."

If the tunnel won't reconnect, re-run the Cloudflare setup: ask Maxy "Reconnect Cloudflare."

If the initial Cloudflare login fails during setup, Maxy will fall back to asking you for a connection key. You can create one in the Cloudflare dashboard (Maxy will guide you through this in the browser).

**If you switched Cloudflare accounts or are stuck on the wrong one:** ask Maxy "Reset my Cloudflare login and start over." This is a clean reset — Maxy clears every stored credential, then opens a fresh browser sign-in. The next sign-in binds to whichever Cloudflare account you choose, with no risk of the previous account's stored credentials silently coming back.

---

## "Bad Gateway" or holding page during an upgrade

`maxy-edge.service` (always-on front door) classifies upstream errors and serves a brand-aware response. There are two distinct user-visible shapes; the right one depends on what failed.

**Branded holding page (brand logo + "Starting") for ~10 s during an upgrade — this is expected and self-healing.** The edge process binds the public port immediately, but `maxy.service` (the upstream UI) takes ~10 s after restart to apply the neo4j schema and mount its 11 routes. Any browser navigation that lands during that window gets a self-contained HTML holding page that polls `/api/health` and reloads automatically once the upstream binds. The page renders the brand logo (inlined as a base64 data URI at edge boot from `<install>/server/public/brand/<assets.logo>`) and the brand display/body fonts (loaded from fonts.googleapis.com) — both paths bypass the unavailable upstream so the page never makes a same-origin asset fetch. When `brand.logoContainsName` is true the logo replaces the productName text; otherwise the page falls back to "Maxy is starting". No operator action required. The diagnostic line in `~/.maxy/logs/edge.log` is `[edge] upstream http error path=… err=connect ECONNREFUSED 127.0.0.1:<UPSTREAM_PORT> err-class=econnrefused-coldstart upstream=…` and disappears as soon as upstream binds. Boot-time confirmation that the logo resolved: `[edge] brand=<name> holding-logo=inlined assets-dir=<path>` — `holding-logo=missing` means the logo file wasn't found at `assets-dir`, the page degrades to text-only.

**Branded plain-text 502 ("Bad Gateway (Maxy unavailable)") — real upstream failure, not cold-start.** Any error class other than `ECONNREFUSED` (timeouts, resets, host-unreachable) returns the existing 502 path. The diagnostic line carries `err-class=other`. Read the log with `tail -200 ~/.maxy/logs/edge.log | rg 'err-class=other'` and check `~/.maxy/logs/server.log` for upstream stack traces — the upstream itself is the source.

**Continuous `err-class=econnrefused-coldstart` for >30 s past the last `[edge] listening` line** indicates the upstream never binds — the upgrade or boot has stalled. Recover via `sudo systemctl --user status maxy.service` and check the action runner log per the next section. Permanent-failure UI escalation (turning the holding page into an error after N seconds) is intentionally deferred.

**The literal string `maxy-ui` should never appear in `edge.log` or in any user-visible 502 body**, regardless of brand. If it does, the edge is running stale code — re-bundle and re-publish.

**Verifying the holding page locally:** `curl -sS -H 'Accept: text/html' http://127.0.0.1:<EDGE_PORT>/` while `maxy.service` is stopped should return HTML containing the brand `productName`. The `Accept: text/html` header is required — non-html clients (default `curl`, `fetch`, XHR) get the branded plain-text 502 instead, so the holding page's own `/api/health` polls don't break themselves during cold-start.

---


## Software update and Cloudflare setup

Both flows run on the native Claude Code PTY surface in admin chat (Task 287). The retired action-runner / terminal-modal troubleshooting sections that lived here have been removed because those surfaces no longer exist; failures now manifest as plain stderr from the agent-invoked Bash command, visible in chat.

- **Software update.** Re-run `npx -y @rubytech/create-<brand>@latest` from a shell; if the installer fails, its stdout is the diagnostic record. HeaderMenu turns sage when `installed === latest`.
- **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `plugins/cloudflare/references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `plugins/cloudflare/references/reset-guide.md` and `plugins/cloudflare/references/manual-setup.md`.

## Orphan Account Directory Archived to `.trash/`

**What happened:** During upgrade, the installer detected multiple account directories under `~/maxy/data/accounts/` and identified one as live (its `admins` list matches the device's `users.json`). Non-matching siblings are archived — not deleted — under `~/maxy/data/accounts/.trash/<uuid>-<ISO8601-ts>/`.

**Installer signal:** Look for these lines in the installer log or admin terminal output:

```
==> [seed] identity-match: kept=<uuid-short> via userId=<first-8>
==> [seed] swept orphan: <uuid-short> →.trash/<uuid-short>-<ts>
==> [seed] orphan sweep: moved N → ~/maxy/data/accounts/.trash/
```

**Rollback (if the wrong account was kept):** The archive is preserved verbatim. Stop the platform, move the desired directory back, restart:

```bash
sudo systemctl --user stop maxy-ui
mv ~/maxy/data/accounts/<live-uuid> ~/maxy/data/accounts/.trash/<live-uuid>-$(date -u +%Y%m%dT%H%M%SZ)
mv ~/maxy/data/accounts/.trash/<archived-uuid>-<ts> ~/maxy/data/accounts/<archived-uuid>
sudo systemctl --user start maxy-ui
```

**`.trash/` retention:** Archived directories are kept indefinitely. The platform never auto-empties `.trash/`. When you're confident the archived orphans are truly obsolete, remove the directory manually: `rm -rf ~/maxy/data/accounts/.trash/<uuid>-<ts>/`.

**Installer aborted with "identity-match FAILED":** Multi-account installs where no sibling matches `users.json[0].userId` abort loud — the installer refuses to pick one and refuses to sweep. Resolution: inspect `account.json` in each candidate dir (listed in the abort output), identify the correct owner, move the other(s) aside manually, then re-run the installer.

**A chat turn looks broken — assistant bubble never rendered:** Open `claude-agent-stream-<sessionKey>.log` and grep for `[sse-client]`. The five phases (`connected`, `event_received`, `render_complete`, `error`, `close`) tell the story in order. Missing `connected` = the chat fetch never returned 200; missing `event_received` = the server emitted nothing or the client lost the stream before the first frame; missing `render_complete` = the reducer never committed the assistant bubble (persist_ack never arrived).

## Admin DevTools console floods with `onboarding-banner-mount` or `sessions-poll` lines

**Regression symptom.** Open DevTools on the admin shell at `/` with `onboardingComplete=false`, leave the page idle for a minute, then scroll back through the console. Thousands of `[admin-ui] onboarding-banner-mount onboardingComplete=false` lines (one per AdminShell render, ~40/min driven by the 3s sessions poll) with no per-tick poll telemetry indicates the banner-mount log has regressed back into the render body.

**Steady-state invariants at `/`:**

- `grep -c '\[admin-ui\] onboarding-banner-mount' ~/.maxy/logs/admin-ui-console.log` equals page-load count plus onboarding-flip count, not the render count. Sustained climb at idle means the banner mount log regressed back into the render body (fix).
- `grep -c '\[admin-ui\] sessions-poll' ~/.maxy/logs/admin-ui-console.log` over a 60-minute idle window equals zero. The hook no longer installs a `setInterval`; every `sessions-poll` line is operator-triggered (initial mount, refresh button, post-mutation refetch). One or more lines during operator idle means `setInterval` was reinstated.
- `outcome=error` lines name a real fetch failure on an operator-triggered refetch, set the `error` field, and surface in the sidebar.

**Reconcile signal:**

- `grep -c '\[admin-ui\] sidebar-meta-pane-reconcile' ~/.maxy/logs/admin-ui-console.log` should equal the count of End / Resume / Purge clicks while the metadata pane was open. A `to=gone` line without a paired Close click means the pane's auto-close logic regressed.

**Why this matters.** The render-body log was misleading: it read as "the admin agent is checking onboarding state continuously", when in fact `onboardingComplete` had not changed at all. The fix moved the log into `useEffect(…, [])` then dropped the per-tick poll entirely, so a quiet console is now the steady state. With both fixes in place, console output is a faithful record of what the page actually did each operator click.
