# 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//plugins//` 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//` 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//`. 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 declared by more than one plugins root: (sha=…) vs (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= 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//` — and the reaper logs `[premium-auto-deliver] reaped standalone= matches-source=` 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 at session end: when you type `/end`, the `session-end-retrospective.sh` Stop hook blocks the close until Maxy walks the session for any node, edge, or commitment that was discussed but not written in-flight, then dispatches one `database-operator` Task per candidate write. The memory graph is stored on your Pi. It never leaves your network. The graph view (at `/graph`) lets you explore the memory directly. Pick a category from the filter, then type to search inside it — typing makes the canvas narrower, not wider. Drag the slider to control how many matches you see (1 to 2000). If the search shows a yellow banner saying "Vector ranking unavailable," it means the local AI ranking model is offline; results are still returned using keyword match, but ordering is less semantic until the ranker recovers. ## The Web Interface The web app runs on your Pi on port 19200. A small always-on front door (`maxy-edge`) owns that port. The edge also hosts the `/api/admin/version` route so the HeaderMenu version display keeps reading even during a mid-restart of the brand service. Login cookies are HMAC-signed with a shared key on disk, so both processes recognise the same session without any coordination and you do not have to log in again after an update. Every request is also classified as LAN or external based on the network shape it arrived on — LAN browsers reach admin directly; the remote password screen only appears on the tunnel-exposed admin domain. It provides: - **Admin chat** (at `/`) — your primary interface, PIN-protected - **Public chat** (at `/{agent-name}`) — visitor-facing agents, each with their own URL. On public hostnames, the root path serves the default agent. - **Telegram bot landing** (at `/bot`) There is no dashboard, no settings panel, no menus. Everything is done through conversation. The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height. The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (the database-operator recorder, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every admin page (Sessions root, Graph, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all three pages. The drag handle is mounted by each AdminShell consumer rather than by AdminShell itself, so any new admin route must include `` as a direct child of its `` 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/.json` for live state, `${CLAUDE_CONFIG_DIR}/projects//.jsonl` for transcripts) and emits `row-created`, `row-updated`, `row-archived`, or `row-removed` deltas to every connected browser tab. Three real delete shapes map to deltas — there is no fourth: PID file gone with JSONL surviving demotes the row to `row-archived`; PID file gone with no JSONL ever written (the per-turn recorder case) emits `row-removed` against the unindexed sessionId; a JSONL deletion against an already-unindexed row also emits `row-removed`. The recorder branch is what reconciles transient hidden spawns — without it, ghost rows persist after the recorder exits. On connect the manager replays the current row index so a freshly-opened tab catches up without polling, then streams deltas as files change on disk. Two open tabs see the same list within ~300ms of any spawn, status flip, or exit; no refresh button required for state to be current. The legacy `/list` fetch and `useAdminSessions` hook stay mounted to serve the ConversationsModal and the post-action reconcile path in `session-actions`, but the sidebar's visible rows come from the row store, not from `/list`. Each EventSource open emits `[admin-events] client-connected ip=<…> seeded-rows=` server-side and `[admin-ui] session-row-store connected events-received=` in the browser console; transport drops log `[admin-ui] session-row-store reconnect trigger= attempt= delay-ms=` 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= kept=` 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=` — `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= code= attempt=`. 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_` 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: `/.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= 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 `/.jsonl` (live) or `/archive/.jsonl` (archived), the sidecar at the matching path with `.meta.json`, and the PID file at `${CLAUDE_CONFIG_DIR}/sessions/.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/.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. The JSONL's first `role=user` line equals the operator's typed text byte-for-byte; Claude Code's `tail.aiTitle` is computed from that real content and remains the canonical sidebar row label. The wrapper at `platform/ui/server/routes/admin/claude-sessions.ts` is still the single canonical entry point for any programmatic admin spawn-with-prompt — see `admin-session.md` "Spawn-with-initialMessage wrapper" and `internals.md` "Programmatic spawn entry point" — and the turn-recorder loopback path forwards its own `initialMessage`. Resume flows are unaffected (the prior transcript is the stimulus). The sidebar row's displayed name is `tail.aiTitle` verbatim, parsed by `jsonl-enumerator.ts` from the JSONL Claude Code writes. Until Claude Code has written its title, the row label is null and the cell renders empty — no UI-stamped sidecar layer, no 8-char id fallback. When Claude Code later updates its title mid-session, the next `/list` or `/events` tick surfaces the new label. Task 146. Each session row also carries a small muted timestamp crumb under the name showing when the session was last active: "just now", "5m", "3h", "yesterday", a weekday name for 2-6 days back, "20 May" for older dates this year, or "20 May 2025" for prior years. Live rows tick forward on their own without a refresh — every row advances together on a single shared 30-second cadence so two rows with identical names (a fresh session whose `aiTitle` has not landed yet, plus a resumed session whose title also has not landed) are distinguishable at a glance. A row that renders "—" instead of a time is a loud-fail signal: the session manager lost the row's `updatedAt` (the JSONL `mtimeMs` for archived rows, the PID-file `updatedAt`/`startedAt` for live rows). Investigate the server log rather than treating "—" as a normal value. The pure formatter lives at `app/lib/relative-time.ts` and is pinned by `app/lib/__tests__/relative-time.test.ts` (every breakpoint, every '—' input, DST cross 2026-03-29); the shared tick is `app/lib/use-now-tick.ts`; the row-render wiring is pinned by `app/__tests__/Sidebar-timestamp.test.tsx`. Task 187. **Stop vs. delete.** `POST //stop` sends SIGTERM, leaves the JSONL on disk for audit, and is idempotent against an already-dead row. `DELETE /` 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 //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= initialBytes= pid=` when the stream opens (after the initial-read flush) and `log-follow-close sessionId= reason=aborted linesStreamed= ms=` 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=` on mount and `[admin-ui] jsonl-viewer-close sessionId=<8> reason=unmount linesRendered= ms=` on unmount, plus `[admin-ui] jsonl-viewer parse-error sessionId=<8> lineNumber=` 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 //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=".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= bytes= ms=` 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 ` 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-` 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= sessionId=<8|new> name=<…|none> mode= jsonl=` | | 2 | `[rc-spawn] op=argv unitToken= cwd= argv=` (inner claude argv; the `systemd-run --scope` wrap is composed by the spawnPty adapter) | | 3 | `[rc-spawn] op=pty-spawned unitToken= pid= openFds=` (fd baseline) | | 4 | `[rc-spawn] op=child-output unitToken= pid= head=` (first ≤1 KB or 500 ms idle — claude's own words) | | 5 | `[rc-spawn] op=early-exit unitToken= pid= ranMs= exitCode= signal=<…>` — fires when `pty.onExit` lands before the pid file | | 6 | `[rc-spawn] op=pidfile-present unitToken= pid= path=.json> ageMs= 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= size=` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) | | 8 | `[rc-spawn] op=exit unitToken= pid= ranMs=` 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= master-fd= fdBefore= fdAfter= fdDelta= removedFds= trackerRemoved= verified=` `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 `; 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= archivedAt= heldSinceArchiveMs= fd=` — fires per tracker whose row is archived (the leak). - `[fd-audit] op=orphan-master-escalate sessionId=<8> fd= heldSinceArchiveMs=` — fires when `heldSinceArchiveMs ≥ 300 000` ms (5 min); strongest leak signal. - `[fd-audit] op=post-archive-sweep archivedSessions= orphanMasters= openFds= livePtys=` — once per sweep. - `[fd-audit] op=master-reconcile liveTrackers= liveSessions= archivedWithMaster= orphanLiveSessionsNoMaster=` — 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= liveSessionsClosed=` 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` 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=` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=` 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=` 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-@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 (`/data/accounts//.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 a transformative summary plus the file path. 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 ` selects the per-source normaliser. Single Bash entry — `bash platform/plugins/memory/bin/conversation-archive-ingest.sh --source --participant-person-ids --scope ` — 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=) 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 "; 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 `` 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 `[] [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//plugins//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//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:]` on the returned stream log. - One server raw feed: `logs-read { type: "mcp" }` → tails the most recent `mcp--stderr-*.log` (per-plugin, not per-conversation). **Tee-state markers** land in the stream log: `[platform] [mcp-tee-attach] server= streamLogPath=...` when the tee wires up, `[platform] [mcp-tee-skip] server= destination=... reason=...` when a destination fails (missing `LOG_DIR`, unwritable path, `STREAM_LOG_PATH` not set, etc.), `[platform] [mcp-tee-detach] server=` on graceful shutdown. If a server invoked tools but no `[mcp:]` 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:] [] ` format so existing `grep '[mcp:]'` 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 ` directly. The wrapper spawns the real entry with `stdio: ['inherit', 'inherit', 'pipe']` and writes child stderr chunks to `${LOG_DIR}/mcp-${name}-stderr-.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= pid=` 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--stderr-.log` and emits `[mcp-init-error] server= tail=` 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), `[mcp-init-error] server= tail=…` (cause for each, from the platform's tail probe), `[mcp-spawn-tee-attached] server= pid=` (proof the wrapper attached), `[mcp-spawn-tee-exit] server= code=|signal=` (proof the wrapper saw the exit), and optionally `[mcp:] [] …` 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 `, and reports `[boot-smoke] memory ok|FAILED tail=`. 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-.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://.local:`. 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:` 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:`. Every brand has its own isolated tree — there is no shared state, and `CLAUDE_CONFIG_DIR` is always `$HOME/./.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://.local: ================================================================ ``` 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. Cloudflare API tokens are never used — only the CLI's interactive `tunnel login` flow. ## 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 ` on a second install path; the URL switches to `.local`. 5. Uninstall removes the LaunchAgent, the plist, and the persist directory. If any step fails, attach `$HOME/./logs/create-maxy-.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. No API tokens are ever issued or stored; the only Cloudflare auth path is `cloudflared tunnel login` running in the Pi's VNC browser after install. - 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 ``` Pick a `` 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 `.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/.service` and enables it with `systemctl --user enable --now`; - prints the LAN URL `http://.local:` 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-.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 ``` The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `.service`, Neo4j is provisioned as a dedicated `neo4j-` 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/./.claude` for that brand, never the default `~/.claude`. ## 3. Confirm the systemd user-service is up ```bash systemctl --user status .service ``` You should see `Active: active (running)`. If the unit is in `failed` or `activating` state, tail the supervised journal: ```bash journalctl --user -u .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 ` so the user-service survives logout. If `loginctl show-user | grep Linger` does not return `Linger=yes`, re-run the installer or `sudo loginctl enable-linger ` 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 auth happens once, interactively, in a browser the operator drives. There is no API token, no service token, no SDK call: the only auth path is `cloudflared tunnel login`, which writes a browser-issued cert to `$HOME/.maxy-code/.cloudflared/cert.pem` on success. Open the Pi's VNC browser at `http://.local:/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 `setup-tunnel` skill walks `cloudflared tunnel login`, `cloudflared tunnel create`, `cloudflared tunnel route dns`, and the systemd `-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://.` 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://.local:` — the install log's final block prints both addresses: ``` ================================================================ Open in your browser: http://.local: Public URL (after Cloudflare setup): https://. ================================================================ ``` ## 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 .service` — confirm `active (running)`. - `journalctl --user -u .service -n 200 --no-pager` — tail the supervisor log. - `loginctl show-user | grep Linger` — confirm `Linger=yes`. Without it the user-service does not start until you SSH in. - `systemctl --user status -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. - **No Cloudflare API tokens.** The only Cloudflare auth path is `cloudflared tunnel login` running in the Pi's VNC browser. If a doc, plugin, or workflow asks for a CF API token it is wrong — surface the discrepancy before proceeding. - **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://.local:` opens the admin UI and the chat surface is interactive. 3. Cloudflare setup driven by the `cloudflare` plugin's `setup-tunnel` skill ends with `curl -I https://.` 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/./logs/install-.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@ ``` ## 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@ ``` 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 ``` Pick a `` 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/.service` and enables it with `systemctl --user enable --now`; - prints the loopback URL `http://localhost:` when the supervisor reports the server is listening. The full install log lands at `$HOME/.maxy-code/logs/install-.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 ``` The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `.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@ # maxy-code # or ssh -L 19200:localhost:19200 -L 6081:localhost:6081 admin@ # 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 auth happens once, interactively, in the noVNC browser the operator drives over the SSH forward from step 4. There is no API token, no service token, no SDK call: the only auth path is `cloudflared tunnel login`, which writes a browser-issued cert to `$HOME/.maxy-code/.cloudflared/cert.pem` on success. In the noVNC browser session, open the admin UI at `http://localhost:`. In chat, ask the agent to run the Cloudflare setup — the [`cloudflare`](../../platform/plugins/cloudflare/PLUGIN.md) plugin's `setup-tunnel` skill walks `cloudflared tunnel login`, `cloudflared tunnel create`, `cloudflared tunnel route dns`, and the systemd `-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://.` 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:` 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 .service systemctl --user status -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. - **No Cloudflare API tokens.** The only Cloudflare auth path is `cloudflared tunnel login` in the noVNC browser over SSH forward. - **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:` displays the admin UI. 4. Cloudflare setup driven by the `cloudflare` plugin's `setup-tunnel` skill ends with `curl -I https://.` returning `HTTP/2 200` from the operator's laptop. 5. Reboot the server; both `.service` and `-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/./logs/install-.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. 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. The agent never reads or mutates Cloudflare account state directly — whatever you see in your logged-in dashboard is the single source of truth. When something needs doing on the account side (adding a domain, deleting a stray entry, switching accounts), the agent relays the click-paths; you run them in your browser. ## 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) | Live Cloudflare dashboard — the operator picks the zone in the dashboard during OAuth or names it in chat. The agent does not enumerate zones programmatically. | | **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `.json`, `config.yml`, `alias-domains.json`. | There is no token-based auth for the operator-owned path (Mode A). 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://` 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, synthesise alternative `cloudflared` flag sequences not in the runbook, call any Cloudflare API or SDK, 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 `** — 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 `** — 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