@rubytech/create-maxy-code 0.1.198 → 0.1.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/package.json +2 -2
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3736 -0
  3. package/payload/platform/plugins/docs/references/admin-ui.md +3 -1
  4. package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +67 -0
  5. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  6. package/payload/platform/services/claude-session-manager/dist/http-server.js +56 -0
  7. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +18 -0
  9. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +25 -15
  11. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  12. package/payload/platform/templates/agents/admin/IDENTITY.md +4 -0
  13. package/payload/platform/templates/agents/public/IDENTITY.md +1 -1
  14. package/payload/server/public/assets/AdminShell-OJlDJakR.js +1 -0
  15. package/payload/server/public/assets/{Checkbox-Bh3kwzxP.js → Checkbox-DE_tsDq_.js} +1 -1
  16. package/payload/server/public/assets/{admin-BWzKAudL.js → admin-DbQw1TyC.js} +1 -1
  17. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Boys2mT1.js → architectureDiagram-Q4EWVU46-DfZVWJVr.js} +1 -1
  18. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CvHqp46r.js → blockDiagram-DXYQGD6D-Cd9vcTYg.js} +1 -1
  19. package/payload/server/public/assets/{brand-VQtREmts.css → brand-Ed5R2W5J.css} +1 -1
  20. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-DiNQ95F6.js → c4Diagram-AHTNJAMY-zmNCfJ5F.js} +1 -1
  21. package/payload/server/public/assets/channel-DCJza6li.js +1 -0
  22. package/payload/server/public/assets/{chunk-336JU56O-9nS9-05M.js → chunk-336JU56O-DvHeoV7B.js} +2 -2
  23. package/payload/server/public/assets/{chunk-426QAEUC-B2__9lRY.js → chunk-426QAEUC-IZknbXMM.js} +1 -1
  24. package/payload/server/public/assets/{chunk-4TB4RGXK-R5F8rS0Z.js → chunk-4TB4RGXK-DN_qw6Oi.js} +1 -1
  25. package/payload/server/public/assets/{chunk-5FUZZQ4R-B8HhJce-.js → chunk-5FUZZQ4R-tt-46Rcq.js} +1 -1
  26. package/payload/server/public/assets/{chunk-5PVQY5BW-Bjb08lbL.js → chunk-5PVQY5BW-Bl0mxmQP.js} +1 -1
  27. package/payload/server/public/assets/{chunk-EDXVE4YY-he0qCF3s.js → chunk-EDXVE4YY-v31HwKDb.js} +1 -1
  28. package/payload/server/public/assets/{chunk-ENJZ2VHE-BTArFW5l.js → chunk-ENJZ2VHE-D6T1RStj.js} +1 -1
  29. package/payload/server/public/assets/{chunk-ICPOFSXX-CoyIL4q_.js → chunk-ICPOFSXX-DVnIs_5A.js} +1 -1
  30. package/payload/server/public/assets/{chunk-OYMX7WX6-DjLEhNFe.js → chunk-OYMX7WX6-DiZNprr1.js} +1 -1
  31. package/payload/server/public/assets/{chunk-U2HBQHQK-tac2uvW1.js → chunk-U2HBQHQK-Cy0qH3H0.js} +1 -1
  32. package/payload/server/public/assets/{chunk-X2U36JSP-DIWD9i88.js → chunk-X2U36JSP-wR3wBPyu.js} +1 -1
  33. package/payload/server/public/assets/{chunk-YZCP3GAM-Ac7ogADz.js → chunk-YZCP3GAM-BGOVJLky.js} +1 -1
  34. package/payload/server/public/assets/{chunk-ZZ45TVLE-QgqDaY9-.js → chunk-ZZ45TVLE-14vtIjRh.js} +1 -1
  35. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DEo4goqG.js +1 -0
  36. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CoAVCeH7.js +1 -0
  37. package/payload/server/public/assets/clone-CLnBlH9n.js +1 -0
  38. package/payload/server/public/assets/{dagre-D6qN4nkK.js → dagre-CG1l6rfK.js} +1 -1
  39. package/payload/server/public/assets/{dagre-KV5264BT-C6TRKOg0.js → dagre-KV5264BT-DKFDl1Nz.js} +1 -1
  40. package/payload/server/public/assets/{data-CaZbNF-j.js → data-b1v7Pjly.js} +1 -1
  41. package/payload/server/public/assets/{diagram-5BDNPKRD-BVk_J8Pt.js → diagram-5BDNPKRD-PJd29fkg.js} +1 -1
  42. package/payload/server/public/assets/{diagram-G4DWMVQ6-BdV1IgwS.js → diagram-G4DWMVQ6-Bz7_SWJH.js} +1 -1
  43. package/payload/server/public/assets/{diagram-MMDJMWI5-OsvBkN6u.js → diagram-MMDJMWI5-CmWknm9v.js} +1 -1
  44. package/payload/server/public/assets/{diagram-TYMM5635-BQmKPyrb.js → diagram-TYMM5635-CLYRluM2.js} +1 -1
  45. package/payload/server/public/assets/{erDiagram-SMLLAGMA-ChEx5_3C.js → erDiagram-SMLLAGMA-EWVKGrgT.js} +1 -1
  46. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-B7sf6AG3.js → flowDiagram-DWJPFMVM-CNtfJIG7.js} +1 -1
  47. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DbpSraax.js → ganttDiagram-T4ZO3ILL-SIlymJ6A.js} +1 -1
  48. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BnxDElDh.js → gitGraphDiagram-UUTBAWPF-DWVl6CUl.js} +1 -1
  49. package/payload/server/public/assets/{graph-CBP5yppq.js → graph-BOcblKu9.js} +1 -1
  50. package/payload/server/public/assets/{graph-labels-BsZNScfa.js → graph-labels-CH-Tffon.js} +1 -1
  51. package/payload/server/public/assets/{graphlib-Dux06enE.js → graphlib-i-qJjJM7.js} +1 -1
  52. package/payload/server/public/assets/{infoDiagram-42DDH7IO-BLWb6oKx.js → infoDiagram-42DDH7IO-D66Yr_7b.js} +1 -1
  53. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-AFiRuE6G.js → ishikawaDiagram-UXIWVN3A-C4mQi7AB.js} +1 -1
  54. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CxHBRGGu.js → journeyDiagram-VCZTEJTY-7Vp2UBlF.js} +1 -1
  55. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CGugF0N8.js → kanban-definition-6JOO6SKY-nEDANSe9.js} +1 -1
  56. package/payload/server/public/assets/{line-COFloj-X.js → line-BxGbTwly.js} +1 -1
  57. package/payload/server/public/assets/{mermaid-parser.core-CxXuCDQf.js → mermaid-parser.core-CAG4AdDd.js} +1 -1
  58. package/payload/server/public/assets/{mermaid.core-BRBNvNup.js → mermaid.core-prtUaAsI.js} +3 -3
  59. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-DarW5Wwq.js → mindmap-definition-QFDTVHPH-Dvcph8zi.js} +1 -1
  60. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CTokFAkN.js → pieDiagram-DEJITSTG-C4qHs6Vb.js} +1 -1
  61. package/payload/server/public/assets/{public-Bq-eOUu9.js → public-DIyn-De4.js} +3 -3
  62. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BNawkg3I.js → quadrantDiagram-34T5L4WZ-I9Et2l2H.js} +1 -1
  63. package/payload/server/public/assets/{requirementDiagram-MS252O5E-CWnO56oR.js → requirementDiagram-MS252O5E-DLKlY81v.js} +1 -1
  64. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D9c-EZfV.js → sankeyDiagram-XADWPNL6-dX3pgMv_.js} +1 -1
  65. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-In5lxCT5.js → sequenceDiagram-FGHM5R23-C4qTAl3F.js} +1 -1
  66. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-CYb5js-u.js → stateDiagram-FHFEXIEX-DzA0NObk.js} +1 -1
  67. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Dsgd5hEe.js +1 -0
  68. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Cje-BY9v.js → timeline-definition-GMOUNBTQ-D-DPUQCU.js} +1 -1
  69. package/payload/server/public/assets/{useSelectionMode-B_2EQ2CC.js → useSelectionMode-C4HDuwDT.js} +1 -1
  70. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BqeuoPr1.js → vennDiagram-DHZGUBPP-C_RwTsUi.js} +1 -1
  71. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DE7xAnoq.js → wardleyDiagram-NUSXRM2D-BAftlsC2.js} +1 -1
  72. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-JE4x8YEZ.js → xychartDiagram-5P7HB3ND-C_tKMCMd.js} +1 -1
  73. package/payload/server/public/data.html +5 -5
  74. package/payload/server/public/graph.html +6 -6
  75. package/payload/server/public/index.html +6 -6
  76. package/payload/server/public/public.html +5 -5
  77. package/payload/server/server.js +399 -245
  78. package/payload/server/public/assets/AdminShell-DPk5UgEJ.js +0 -1
  79. package/payload/server/public/assets/channel-BgwH_qhE.js +0 -1
  80. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CwdV1-c8.js +0 -1
  81. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CS_jtevB.js +0 -1
  82. package/payload/server/public/assets/clone-BTishuSs.js +0 -1
  83. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-z-TVJfRt.js +0 -1
  84. /package/payload/server/public/assets/{brand-CzeO4m4J.js → brand-CCOPpjM4.js} +0 -0
@@ -0,0 +1,3736 @@
1
+ ---
2
+ name: platform-architecture
3
+ description: Use when grounding any claim about what Maxy is, how the platform is built, how plugins/skills/specialists work, how install/deploy runs, or any other product-architecture fact. The body of this skill is the only permissible source for such claims. Cite the `Source:` URL inline.
4
+ content-hash: sha256:d813d3aa74c28275154b70989146c196871bdd1518b1a86881d64b620b1911a0
5
+ ---
6
+
7
+ # Platform architecture (reference)
8
+
9
+ This skill is generated. Its body is the concatenated source markdown for every public Maxy docs page, emitted by `docs/scripts/copy-docs.mjs` as `docs/public/llms-full.txt` and assembled here by `maxy-code/scripts/assemble-architecture-skill.mjs`. Do not hand-edit — the drift gate in `platform/scripts/check-architecture-skill-no-drift.mjs` will fail CI.
10
+
11
+ When you load this skill to ground a factual claim, cite the `Source:` URL printed above the block you drew from. The URL is the canonical public docs page; relay it as a markdown link in your reply. Training-data recall is not a permitted source — the body below is.
12
+
13
+ ---
14
+ # Maxy Documentation — full corpus
15
+
16
+ Concatenated source markdown for every public Maxy docs page. Pages are separated by `---` and labelled with their canonical URL.
17
+
18
+ ---
19
+ # Getting Started
20
+ Source: https://docs.getmaxy.com/getting-started.md
21
+
22
+ # Getting Started with Maxy
23
+
24
+ ## What Maxy Is
25
+
26
+ 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.
27
+
28
+ 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.
29
+
30
+ Because Maxy runs locally, your data stays in your home. It never passes through someone else's cloud.
31
+
32
+ ## The Two Interfaces
33
+
34
+ **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.
35
+
36
+ **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.
37
+
38
+ ## First Power-On (New Device)
39
+
40
+ If your device has no WiFi configured and no ethernet cable connected, it creates a temporary WiFi network for setup:
41
+
42
+ 1. Power on the device and wait about 60 seconds
43
+ 2. On your phone, look for a WiFi network called **{ProductName}-Setup** (e.g. `Maxy-Setup`)
44
+ 3. Connect to that network — a setup page opens automatically
45
+ 4. Select your home WiFi network from the list and enter the password
46
+ 5. The device connects to your WiFi and the temporary network disappears
47
+ 6. Your phone automatically reconnects to your home WiFi
48
+
49
+ After WiFi is configured, open your browser and go to `{hostname}.local:19200` (the setup page shows this address).
50
+
51
+ If you already have ethernet connected, the temporary WiFi network does not appear — go directly to the admin interface.
52
+
53
+ ## First Run
54
+
55
+ When you first open the admin interface:
56
+
57
+ 1. Set your PIN — this protects access to the admin interface
58
+ 2. Connect to Claude — Maxy will guide you through connecting to your Claude account
59
+ 3. Enter your PIN to log in
60
+ 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
61
+
62
+ This setup is resumable — if you close the browser mid-setup, Maxy picks up where you left off next time.
63
+
64
+ 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.
65
+
66
+ ## How to Use Maxy
67
+
68
+ Conversation is the only interface. Type or speak what you need:
69
+
70
+ - "Add John Smith to my contacts, he's a potential client from the conference"
71
+ - "Schedule a call with Sarah for Thursday at 2pm"
72
+ - "What did I last discuss with Tom?"
73
+ - "Send a Telegram message to the team: standup in 10 minutes"
74
+ - "Create a one-pager PDF about our new product launch"
75
+
76
+ Maxy understands plain language. You don't need to learn commands or navigate menus.
77
+
78
+ ### Voice Notes
79
+
80
+ 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.
81
+
82
+ Voice recording requires a secure connection (HTTPS). When accessing Maxy over the local network via HTTP, use the tunnel URL for voice notes.
83
+
84
+ 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.
85
+
86
+ ## What Maxy Remembers
87
+
88
+ 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.
89
+
90
+ 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."
91
+
92
+ ## Reaching Your Data When Maxy Is Unavailable
93
+
94
+ 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:
95
+
96
+ - **Graph search** — type a keyword to search your knowledge documents, sections, and notes directly in Neo4j. No AI involved.
97
+ - **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/`.
98
+
99
+ 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.
100
+
101
+ ## Getting Help
102
+
103
+ 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?"
104
+
105
+ ---
106
+ # How Maxy Works
107
+ Source: https://docs.getmaxy.com/platform.md
108
+
109
+ # How Maxy Works
110
+
111
+ ## The Short Version
112
+
113
+ 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.
114
+
115
+ ## The Raspberry Pi
116
+
117
+ Maxy is a server that lives on your local network. It's always on, always available, and accessible:
118
+
119
+ - **Locally:** `maxy.local:19200` (or the IP address of your Pi)
120
+ - **Remotely:** via your personal domain, routed through a Cloudflare tunnel
121
+
122
+ 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.
123
+
124
+ ## The Two Agents
125
+
126
+ Maxy runs two agents simultaneously:
127
+
128
+ **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.
129
+
130
+ **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.
131
+
132
+ ## Plugins
133
+
134
+ Everything Maxy can do is provided by a plugin. Each plugin is a self-contained package:
135
+
136
+ - Behaviour instructions (how the agent should act)
137
+ - Tools (specific actions the agent can take, exposed via MCP servers)
138
+ - Reference documents (detailed knowledge loaded on demand)
139
+
140
+ **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.
141
+
142
+ **Where premium bundle subs live.** Bundle subs (`loop`, `property-data`, `brochures`, etc. inside `real-agent`) live exclusively at `premium-plugins/<bundle>/plugins/<sub>/` and are registered via the resolver's bundle-descent walk. Standalone premiums (no `BUNDLE.md`, e.g. `writer-craft`, `teaching`, `venture-studio`) live exclusively at `premium-plugins/<name>/` and are registered via the resolver's dual-root scan (`platform/plugins/` and `premium-plugins/` are both `pluginsRoots`). Neither shape is flat-copied into `platform/plugins/<name>/`. A divergent flat copy of a bundle sub is treated as an operator override: the resolver refuses to boot with `boot-failed reason=mcp-plugin-duplicate <plugin> declared by more than one plugins root: <pathA> (sha=…) vs <pathB> (sha=…)` so the operator can `sha256sum` both paths and remove the stale one. Byte-identical bundle-sub flat copies left over from installer versions that flat-copied bundle subs are reaped on the first post-upgrade boot (`[premium-auto-deliver] reaped sub=<name> reason=duplicate-of-premium-tree`). Standalone flat copies (leaked by the pre-fix `autoDeliverPremiumPlugins` standalone branch) are reaped unconditionally — there is no documented override path for standalones at `platform/plugins/<name>/` — and the reaper logs `[premium-auto-deliver] reaped standalone=<name> matches-source=<true|false>` so divergent reaps leave a forensic trail.
143
+
144
+ Plugins are installed and managed through conversation. You can add marketplace plugins (like Stripe) or use Maxy's built-in ones (contacts, memory, Telegram).
145
+
146
+ ## Roles
147
+
148
+ Maxy has six 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.
149
+
150
+ | Role | What it does |
151
+ |------|-------------|
152
+ | Personal Assistant | Scheduling, platform administration, messaging channels, and browser automation |
153
+ | Project Manager | Task and project management — creating, tracking, and completing work items |
154
+ | Research Assistant | Web research, knowledge management, and visual production |
155
+ | Content Producer | Image generation, PDF output, and static-site hosting from prepared archives |
156
+ | Librarian | Foreground ingester for the memory graph — owns document ingestion (PDF / text / web), conversation-transcript ingestion (WhatsApp, Telegram, Signal, iMessage, Slack, Zoom, meeting minutes, LinkedIn DMs), LinkedIn Basic Data Exports, and post-unzip trees the admin agent forwards. Returns a structured outcome to admin per ingest. |
157
+ | Database Operator | Background recorder for the memory graph — a Stop hook fires this specialist as a hidden PTY after every completed admin turn, and it writes whatever the turn implied into the graph. The admin agent itself never writes; it asks, listens, and acts on what the recorder has already captured. |
158
+
159
+ Roles are installed during setup and listed when Maxy introduces itself. Some premium plugins add their own specialists (e.g. the writer-craft plugin adds a manuscript reviewer). Roles installed mid-session become active from the next session.
160
+
161
+ ## Memory
162
+
163
+ 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.
164
+
165
+ **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.
166
+
167
+ The memory graph is stored on your Pi. It never leaves your network.
168
+
169
+ 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.
170
+
171
+ ## The Web Interface
172
+
173
+ 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:
174
+
175
+ - **Admin chat** (at `/`) — your primary interface, PIN-protected
176
+ - **Public chat** (at `/{agent-name}`) — visitor-facing agents, each with their own URL. On public hostnames, the root path serves the default agent.
177
+ - **Telegram bot landing** (at `/bot`)
178
+
179
+ There is no dashboard, no settings panel, no menus. Everything is done through conversation.
180
+
181
+ 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.
182
+
183
+ The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (the database-operator recorder, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every admin page (Sessions root, Graph, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all three pages. The drag handle is mounted by each AdminShell consumer rather than by AdminShell itself, so any new admin route must include `<SidebarSplitter />` as a direct child of its `<AdminShell>` to pick up the shared width. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
184
+
185
+ 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.
186
+
187
+ **Session lifecycle and reconcile model.** The sidebar Sessions list is driven by a single Server-Sent Events feed at `/api/admin/claude-sessions/events`. The session manager watches the two directories Claude Code writes (`${CLAUDE_CONFIG_DIR}/sessions/<pid>.json` for live state, `${CLAUDE_CONFIG_DIR}/projects/<slug>/<sid>.jsonl` for transcripts) and emits `row-created`, `row-updated`, `row-archived`, or `row-removed` deltas to every connected browser tab. Three real delete shapes map to deltas — there is no fourth: PID file gone with JSONL surviving demotes the row to `row-archived`; PID file gone with no JSONL ever written (the per-turn recorder case) emits `row-removed` against the unindexed sessionId; a JSONL deletion against an already-unindexed row also emits `row-removed`. The recorder branch is what reconciles transient hidden spawns — without it, ghost rows persist after the recorder exits. On connect the manager replays the current row index so a freshly-opened tab catches up without polling, then streams deltas as files change on disk. Two open tabs see the same list within ~300ms of any spawn, status flip, or exit; no refresh button required for state to be current. The legacy `/list` fetch and `useAdminSessions` hook stay mounted to serve the ConversationsModal and the post-action reconcile path in `session-actions`, but the sidebar's visible rows come from the row store, not from `/list`. Each EventSource open emits `[admin-events] client-connected ip=<…> seeded-rows=<n>` server-side and `[admin-ui] session-row-store connected events-received=<n>` in the browser console; transport drops log `[admin-ui] session-row-store reconnect trigger=<auto|manual> attempt=<n> delay-ms=<n>` until the EventSource reattaches. The small dot at the right edge of the Active/Archived/All segmented control is the live-updates indicator: sage when the SSE feed is connected, grey when the feed has dropped. The grey state is an actionable button — clicking it cancels any pending backoff and re-opens the feed immediately, with the click logged as `trigger=manual` so manual retries are distinguishable from automatic ones in the console. The refresh icon at the top of the Sessions list is the operator-recoverable reconcile path against any SSE gap: it fetches `/api/admin/claude-sessions` and passes the authoritative id set to the row store, which evicts any indexed row that the server no longer reports. SSE replay only re-asserts currently-indexed rows and never emits `row-removed` for a row that vanished while disconnected, so without this manual surface a stale row can persist until the operator reloads the tab. Each click logs `[admin-ui] session-row-store reconcile evicted=<n> kept=<n>` when at least one row is evicted, and is silent otherwise.
188
+
189
+ The row feed sits behind `requireAdminSession` like every other admin route, so the URL must carry `?session_key=<cacheKey>` — `EventSource` cannot send custom headers, so the query string is the only viable transport. Every admin URL (fetch and EventSource alike) routes through the shared `appendAdminSessionKey(url, cacheKey)` helper exported from `app/lib/useAdminFetch.ts`, which is the single source of truth for the convention; no caller constructs the query string by hand. On a 4xx rejection the browser-side store probes the same URL once per reconnect (suppressed after a successful `open`, capped at one fetch per attempt) and logs `[admin-ui] session-row-store sse-error status=<n> code=<code> attempt=<n>`. The `code` field uses the closed `AdminSessionRejectCode` taxonomy (`session-missing | session-not-registered | session-expired-age | grant-expired`, plus a default `unknown` bucket) that mirrors the server-side rejection emitted by `requireAdminSession`, so a single grep correlates client and server timelines on the same code.
190
+
191
+ 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.
192
+
193
+ The row payload carries `url: string | null` (Tasks 189 / 260) — the `claude.ai/code/session_<suffix>` URL captured from the `/remote-control` banner. **Task 260 — disk is the only source of truth.** Spawn metadata that previously lived in an in-memory `SessionStore` (senderId, role, channel, url, startedAt, permissionMode, model, hidden, specialist) now rides a sidecar file alongside the JSONL: `<projectsDir>/<sessionId>.meta.json`. The watcher reads the sidecar at row-build time and stamps the nine fields onto the `SessionRow`; the serialiser reads `row.url` directly with no in-memory side channel. The value is `null` whenever the spawn is headless (`HEADLESS_ROLES`, Task 171 — `--remote-control` not passed), or before url-capture has fired on a channel-facing spawn (~2 s after spawn), or on rows whose JSONL+sidecar pair was archived before the banner landed. When url-capture eventually fires, `pty-spawner` writes the URL to the sidecar via `updateSidecar`, calls `watcher.refreshSidecar(sessionId)` to refresh the row index, and the manager pushes a `row-updated` SSE frame carrying the fresh URL — the client's Open-in-new-tab arrow appears in step. The Sidebar gates the arrow on `row.live && row.url !== null` and opens `row.url` directly with no `/meta` round-trip; each click logs `[admin-ui] sidebar-open-in-new-tab outcome=<ok|blocked> sessionId=<8-char>` (`blocked` fires when a popup blocker swallows `window.open`).
194
+
195
+ **Manager state shape (Task 260).** The manager keeps exactly two pieces of in-process state — the live `PtyHandle` map (in `pty-spawner.ts`, keyed on sessionId, holding the file descriptor and runtime flags that cannot go on disk) and the watcher's row index (rebuilt from disk on each event). Everything else lives on disk: the JSONL transcript at `<projectsDir>/<sessionId>.jsonl` (live) or `<projectsDir>/archive/<sessionId>.jsonl` (archived), the sidecar at the matching path with `.meta.json`, and the PID file at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. A manager restart re-reads the sidecars at boot so every row that had one before the restart re-enters the in-memory index with full senderId/role/channel populated. Pre-Task-260 archived JSONLs (created before the sidecar writer existed) index normally but with seven null sidecar fields. The watcher enumerates BOTH the top-level projects dir AND its `archive/` subdir, watches both with `fs.watch`, and coalesces a top↔archive rename into one `row-updated` event (no `row-removed` followed by `row-created` — the rename is one logical state change keyed on sessionId). The sidebar surface that consumes this index is `/api/admin/sidebar-sessions` (Task 538), not the legacy session-manager `/list` route, which has been removed.
196
+
197
+ **Spawn lifecycle: PID-file driven.** Clicking "+ New session" opens the `NewSessionModal` (Task 223). Modal submit POSTs to the wrapper with the operator's typed text as `initialMessage`, plus per-session `permissionMode` and `model` overrides; only then does the PTY spawn. The manager waits for Claude Code's PID file at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. The JSONL's first `role=user` line equals the operator's typed text byte-for-byte; Claude Code's `tail.aiTitle` is computed from that real content and remains the canonical sidebar row label. The wrapper at `platform/ui/server/routes/admin/claude-sessions.ts` is still the single canonical entry point for any programmatic admin spawn-with-prompt — see `admin-session.md` "Spawn-with-initialMessage wrapper" and `internals.md` "Programmatic spawn entry point" — and the turn-recorder loopback path forwards its own `initialMessage`. Resume flows are unaffected (the prior transcript is the stimulus).
198
+
199
+ 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.
200
+
201
+ 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.
202
+
203
+ **Stop vs. delete.** `POST /<id>/stop` sends SIGTERM, leaves the JSONL on disk for audit, and is idempotent against an already-dead row. `DELETE /<id>` removes the JSONL + per-session subdir and returns 409 if the PTY is still alive (stop first). Any unknown id returns 404; nothing returns a silent 204 against an id the manager does not know.
204
+
205
+ **View JSONL (Task 198).** Alongside the Download button, the pane carries a **View JSONL** button that opens a full-pane modal streaming the transcript in-app from `GET /<id>/log?follow=1`. The modal is the canonical surface for reading transcripts inside the admin UI — Download remains the export route for offline / external tooling. The viewer renders one row per line, collapsed by default to a role badge plus a 200-char preview; click a row to expand into the pretty-printed JSON, click again to collapse, or click the copy icon to copy the raw line bytes (round-trip integrity preserved — no re-stringify). A search input filters visible rows by case-insensitive substring match against the line's JSON; the stream keeps landing in the backing list regardless of filter state. For live sessions (`status: 'alive'`) the modal tails new lines as they're written; for ended sessions it renders the initial-read flush and then idles. The status pill in the footer reflects the live session status (alive → "streaming", ended → "complete"), not the underlying stream state — keeps the operator's mental model aligned with the pane's other indicators even though the manager's `/log?follow=1` keeps the underlying watcher open until aborted. Malformed lines (`JSON.parse` failure) render inline as a `parse-error` row with the raw text and the failure reason; the stream continues. The backing list caps at 50,000 entries (ring buffer with eldest-drop); past the cap, the header reads "N older dropped" — the cap protects browser memory on multi-day database-operator sessions where the JSONL can grow to tens of thousands of lines. Closing the modal (X button, overlay click, or Escape) aborts the fetch (`AbortController.abort()`) which propagates to the manager's `out.onAbort` and releases the `watchFile` listener. Observability: the manager emits `[claude-session-manager] log-follow-open sessionId=<sid> initialBytes=<n> pid=<n>` when the stream opens (after the initial-read flush) and `log-follow-close sessionId=<sid> reason=aborted linesStreamed=<n> ms=<n>` when it closes — `linesStreamed` counts `\n` bytes written across both initial-read and tail, matching `wc -l`. The browser console mirrors with `[admin-ui] jsonl-viewer-open sessionId=<8> alive=<bool>` on mount and `[admin-ui] jsonl-viewer-close sessionId=<8> reason=unmount linesRendered=<n> ms=<n>` on unmount, plus `[admin-ui] jsonl-viewer parse-error sessionId=<8> lineNumber=<n>` once per malformed line (capped at 100/session to avoid console flood). Auth is unchanged — the existing `requireAdminSession` middleware covers `/log?follow=1` exactly as it already does for `/log?download=1`.
206
+
207
+ **Download JSONL (Task 197).** `GET /<id>/log?download=1` is a one-shot byte-stream of the session's JSONL transcript with attachment-disposition headers, designed for the pane's **Download JSONL** button. Headers: `Content-Type: application/x-ndjson`, `Content-Disposition: attachment; filename="<sessionId>.jsonl"` (the basename is sanitised so any non-`[A-Za-z0-9._-]` character is replaced with underscore), `Cache-Control: no-store`. Four status branches: **200** with the byte-identical file body; **404** `{error: 'session-not-found'}` when the store has no row for the id; **202** `{pending: true, jsonlPath: null}` when the row exists but claude has not flushed the first turn yet; **404** `{error: 'jsonl-missing-on-disk'}` when the row carries a `jsonlPath` but the file has been removed under the manager (post-Purge race). The download branch is declared **before** the follow check, so `?download=1` always wins over `?follow=1` if both are set. The proxy at `app.get('/:sessionId/log')` rebuilds the upstream query from a fixed `follow|download` allowlist; inbound query keys outside that allowlist are dropped. Observability: `[claude-session-manager] log-download sessionId=<sid> bytes=<n> ms=<n>` lands per successful stream completion; the browser console emits `[admin-ui] pane-download-jsonl sessionId=<8> outcome=initiated` on click. `outcome=initiated` rather than `outcome=ok` is intentional — the handler resolves before the browser writes the bytes, so the log line names "the request was kicked off", not "the file landed". If the file does not appear in the operator's downloads folder, check the manager line for the bytes count and the browser's downloads UI for the suppression record. Auth is unchanged from the rest of the `/api/admin/claude-sessions` surface (cookie session via `requireAdminSession`); there is no new key surface.
208
+
209
+ **PTY lifecycle contract (Tasks 170 + 176 + 260).** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `killSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the in-process tracker entry is removed before the next `/list` or `/events` tick. As of Task 260 the tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
210
+
211
+ 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.
212
+
213
+ **Admin URL hygiene: `?sessionId=<id>` is retained only while `/meta` returns 200.** The shell hydrates `selectedSessionId` from the query-string on mount so a banner-click redirect can re-open a session. The first `/meta 404` (the session has been deleted out from under the slug) strips the query-string via `history.replaceState`, clears the selection, and emits `[admin-ui] stale-session-slug-stripped sessionId=<8-prefix> trigger=meta-404`. A reload from the dead URL therefore starts at base instead of re-resolving a 404.
214
+
215
+ 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.
216
+
217
+ ## Software Update and Cloudflare Setup
218
+
219
+ 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.
220
+
221
+ - **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`) from a shell; HeaderMenu's version row turns sage when `installed === latest`.
222
+ - **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.
223
+
224
+ **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.
225
+
226
+ **Authorisation** is inherited from the same `canAccessAdmin()` gate that wraps every `/api/admin/*` route.
227
+
228
+ ## AI Content Provenance
229
+
230
+ 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.
231
+
232
+ - **Email:** an `X-AI-Generated` header and a footer line are added to every outbound email
233
+ - **WhatsApp and Telegram:** a short line is appended to the message body
234
+ - **SMS:** a brief suffix is appended when the message is AI-composed
235
+
236
+ Messages you write yourself (e.g. typing directly in WhatsApp) are not marked — the disclosure applies only to content composed by the AI agent.
237
+
238
+ ## Session Slot Safeguards
239
+
240
+ 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:
241
+
242
+ - **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`.
243
+ - **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.
244
+ - **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.
245
+
246
+ 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".
247
+
248
+ 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.
249
+
250
+ ---
251
+ # Plugins Guide
252
+ Source: https://docs.getmaxy.com/plugins-guide.md
253
+
254
+ # Plugins Guide
255
+
256
+ ## What a Plugin Is
257
+
258
+ 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.
259
+
260
+ Maxy's own capabilities are plugins too. Marketplace plugins (like Stripe) work the same way — Maxy manages all of them through conversation.
261
+
262
+ ## Plugin Groups
263
+
264
+ ### Core (always active)
265
+
266
+ These are part of Maxy's foundation and cannot be disabled:
267
+
268
+ | Plugin | What it does |
269
+ |--------|-------------|
270
+ | `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. |
271
+ | `memory` | Graph memory — search, write, reindex, and ingest knowledge |
272
+ | `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). |
273
+ | `maxy-guide` | User guide and platform documentation (this plugin) |
274
+ | `cloudflare` | Cloudflare Tunnel — remote access via your custom domain |
275
+ | `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. |
276
+ | `email` | Agent email account — setup, read, send, reply, search, auto-respond |
277
+ | `tasks` | Task lifecycle — create, update, list, relate, complete |
278
+ | `workflows` | Persistent named workflows — reusable instruction sets |
279
+ | `contacts` | CRM contact management — create, lookup, update, list |
280
+ | `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. |
281
+ | `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). |
282
+
283
+ ### Maxy Plugins (user-selectable)
284
+
285
+ 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.
286
+
287
+ | Plugin | What it does | Enhances |
288
+ |--------|-------------|----------|
289
+ | `business-assistant` | Customer enquiries, scheduling, quoting, invoicing, daily briefings | Personal assistant |
290
+ | `sales` | Buying signal detection, closing techniques, objection handling | Personal assistant |
291
+ | `deep-research` | Structured multi-source research — query decomposition, source evaluation, citations | Research assistant |
292
+ | `projects` | Structured project execution — phased sprints, investigations, reviews, retrospectives | Project manager |
293
+ | `telegram` | Telegram bot — BotFather setup, messaging, channels | Personal assistant |
294
+ | `whatsapp` | WhatsApp messaging, pairing, and conversation browsing | Personal assistant |
295
+ | `replicate` | Image generation — three models for photorealistic, design, and fast draft images | Content producer, Research assistant |
296
+ | `linkedin-import` | Import a LinkedIn Basic Data Export — Profile and Connections today, more CSVs as references land | Database operator |
297
+ | `notion-import` | Import a Notion workspace export (markdown + CSV) — pages, databases, hierarchy, attachments, schema-bounded relations, `@person` mentions account-filtered | Database operator |
298
+ | `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 |
299
+ | `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 |
300
+ | `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 |
301
+ | `memory/skills/conversation-archive` | Source-agnostic conversation transcript ingest. One skill for WhatsApp `_chat.txt`, Telegram, Signal, LinkedIn DMs, Zoom transcript, meeting minutes, iMessage, Slack, X DMs — `--source <enum>` selects the per-source normaliser. Single Bash entry — `bash platform/plugins/memory/bin/conversation-archive-ingest.sh <archive> --source <enum> --participant-person-ids <csv> --scope <admin\|public>` — runs normalise → operator-confirms owner + every distinct sender (owner derived from env via Cypher, no flag) → sessionize at the fixed 8h gap → emit one JSON line carrying prepared sessions (turn-attributed text + per-session cursor). The dispatched specialist iterates the sessions in-turn, produces a typed-section JSON chunking for each, and calls the `memory-ingest` MCP tool with `conversationIdentity` set (writes `:ConversationArchive`, source=<enum>) once per session — chunks + cursor advance commit atomically inside one Cypher transaction, so a kill mid-archive resumes from the next session on re-issue without re-classifying anything already written. Re-imports are delta-append. Auto-creating participants is forbidden — any sender outside the operator-confirmed closed set LOUD-FAILs with `parser-miss`. Distinct from the live `whatsapp` plugin (Baileys). | Database operator |
302
+ | `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 |
303
+
304
+ ### Claude Official (marketplace)
305
+
306
+ Third-party plugins from the Claude marketplace:
307
+
308
+ | Plugin | What it does |
309
+ |--------|-------------|
310
+ | `stripe` | Live access to payment and business data |
311
+
312
+ ### Claude Anthropic Verticals (marketplace, opt-in)
313
+
314
+ 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.
315
+
316
+ | Plugin | Marketplace | What it does |
317
+ |--------|-------------|-------------|
318
+ | `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. |
319
+ | `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. |
320
+ | `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). |
321
+
322
+ Install verbatim:
323
+
324
+ - `claude plugin install kyc-screener@claude-for-financial-services`
325
+ - `claude plugin install meeting-prep-agent@claude-for-financial-services`
326
+ - `claude plugin install pdf-viewer@knowledge-work-plugins`
327
+
328
+ ### Premium Plugins
329
+
330
+ Brand decides which premium plugins ship. The brand's `shipsPremiumBundles` field in `brand.json` is the gate; three shapes are supported:
331
+
332
+ - **omitted / false** — ship nothing from `premium-plugins/` (the legacy Maxy default).
333
+ - **`true`** — ship every bundle under `premium-plugins/*` (Real Agent / `realagent-code`).
334
+ - **`["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.
335
+
336
+ There is no per-account purchase record; the brand decides the shipping set.
337
+
338
+ | Plugin | Type | What it does | Public agent |
339
+ |--------|------|-------------|-------------|
340
+ | `teaching` | Skills | Interactive tutoring, lesson planning, and study pack generation from your knowledge base | Yes — all 3 skills serve students and parents |
341
+ | `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) |
342
+ | `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 |
343
+ | `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 |
344
+
345
+ **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.
346
+
347
+ Some premium plugins are **bundles** — multiple sub-plugins shipped under one directory in `premium-plugins/`, each independently activatable. For example, Real Agent ships 10 sub-plugins covering different aspects of estate agency work. They are all enabled by default. Sub-plugins you don't want active can be turned off individually with "disable <name>"; enabling or disabling individual sub-plugins does not affect the others.
348
+
349
+ If you ask Maxy about a tool from a plugin your brand does not ship (for example, a Maxy install asking about a Real Agent Loop CRM tool), Maxy responds with a structured `<tool-surface-error>` envelope naming the missing plugin and the remedy, rather than improvising with a generic alternative.
350
+
351
+ **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.
352
+
353
+ 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.
354
+
355
+ 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.
356
+
357
+ 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.
358
+
359
+ **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.
360
+
361
+ ## Choosing Plugins
362
+
363
+ 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.
364
+
365
+ ## Adding or Removing Plugins
366
+
367
+ Tell Maxy:
368
+
369
+ - "Enable the Telegram plugin"
370
+ - "Add the Stripe plugin"
371
+ - "Disable the deep-research plugin"
372
+
373
+ Maxy handles the installation or removal. If the plugin requires any setup (API keys, bot tokens, configuration), Maxy will walk you through it.
374
+
375
+ ## Viewing Your Plugins
376
+
377
+ Ask Maxy: "What plugins do I have?" or "List my plugins."
378
+
379
+ ## Operator-Authored Plugins (skill-builder output)
380
+
381
+ 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.
382
+
383
+ 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.
384
+
385
+ 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.
386
+
387
+ `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.
388
+
389
+ ## Brand Templating (for plugin and skill authors)
390
+
391
+ 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.
392
+
393
+ **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.
394
+
395
+ 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.
396
+
397
+ ## MCP Plugin Observability (for plugin authors)
398
+
399
+ 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.
400
+
401
+ **Opt-in (one line at the top of the MCP server's entry file):**
402
+
403
+ ```typescript
404
+ import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
405
+ initStderrTee("your-plugin-name");
406
+ ```
407
+
408
+ After this, every `console.error("[your-tool]...")` from any tool in the plugin appears as `[<iso-ts>] [mcp:your-plugin-name] [your-tool]...` in the per-conversation stream log `claude-agent-stream-{sessionId}.log`, alongside the usual agent events. The raw per-server file `mcp-your-plugin-name-stderr-{date}.log` is still produced for deep-dive grep.
409
+
410
+ **Premium plugins.** Source lives at `premium-plugins/<bundle>/plugins/<name>/mcp/src/` — deeper than platform plugins, so the source-relative import to `platform/lib/mcp-stderr-tee/dist/index.js` uses more `../` segments. The bundler rewrites the compiled output to the canonical `../../../../lib/mcp-stderr-tee/dist/index.js` at staging time and ships `platform/lib/mcp-stderr-tee/{dist,package.json}` into `premium-plugins/<bundle>/lib/mcp-stderr-tee/` so the import resolves at deployed depth. The bundler fails loudly if `platform/lib/mcp-stderr-tee/package.json` is missing (it must pin `type` so install-location parent walks cannot mis-classify the dist file) or if any lib referenced by a rewritten import has no source dist.
411
+
412
+ **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.
413
+
414
+ **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.
415
+
416
+ **Retrieve MCP diagnostic lines for a conversation:**
417
+
418
+ - All servers: `logs-read { type: "system", sessionId: "..." }` → grep `[mcp:<name>]` on the returned stream log.
419
+ - One server raw feed: `logs-read { type: "mcp" }` → tails the most recent `mcp-<name>-stderr-*.log` (per-plugin, not per-conversation).
420
+
421
+ **Tee-state markers** land in the stream log: `[platform] [mcp-tee-attach] server=<name> streamLogPath=...` when the tee wires up, `[platform] [mcp-tee-skip] server=<name> destination=... reason=...` when a destination fails (missing `LOG_DIR`, unwritable path, `STREAM_LOG_PATH` not set, etc.), `[platform] [mcp-tee-detach] server=<name>` on graceful shutdown. If a server invoked tools but no `[mcp:<name>]` lines appear in the conversation's log, look for the skip marker first.
422
+
423
+ **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.
424
+
425
+ ## Failure-path observability contract (earlier platform fixes + earlier platform fixes)
426
+
427
+ 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.
428
+
429
+ **Two layers now close the gap, each load-bearing on its own:**
430
+
431
+ 1. **Plugin-side sync-write discipline.** Plugins that call `process.exit` during module load (rare — `graph-mcp` is the in-tree example; it spawns a child at boot to proxy upstream stdio) use `fs.appendFileSync` at every named exit path to guarantee the cause lands in both log destinations before exit. Lines follow the `[mcp:<name>] [<plugin-prefix>] <cause>` format so existing `grep '[mcp:<name>]'` investigator paths work. Each destination is wrapped in its own try/catch — an unwritable log must not mask the primary failure. This is the discipline propagated to any plugin author who knows their failure paths.
432
+
433
+ 2. **Parent-side `mcp-spawn-tee` wrapper.** Every node-based core MCP server is spawned via the `lib/mcp-spawn-tee` wrapper rather than `node <entry>` directly. The wrapper spawns the real entry with `stdio: ['inherit', 'inherit', 'pipe']` and writes child stderr chunks to `${LOG_DIR}/mcp-${name}-stderr-<date>.log` via `appendFileSync` while passing the same chunks through to its own stderr (Claude Code's consumer is unchanged). Synchronous `appendFileSync` survives `process.exit`, so the per-server file captures even (a) module-load throws before `initStderrTee` runs, (b) `MODULE_NOT_FOUND` on the entry script itself, and (c) anything else a plugin author missed. The wrapper writes `[mcp-spawn-tee-attached] server=<name> pid=<n>` on attach and forwards SIGTERM/SIGINT to the child. This is the layer that makes capture independent of plugin discipline. Playwright stays unwrapped because it spawns via `npx`, not `node`.
434
+
435
+ A third layer closes the same gap from the platform side: when `claude-agent.ts` observes an `init` event with any MCP server reporting `status:"failed"`, it reads the last 512 bytes of `${LOG_DIR}/mcp-<name>-stderr-<date>.log` and emits `[mcp-init-error] server=<name> tail=<quoted>` into the stream log. Absent file → `tail="(no stderr file)"`; empty file → `tail="(empty)"`. With the spawn-tee wrapper now interposing on every core MCP, `tail="(no stderr file)"` post-Task-743 means the wrapper itself is broken — file follow-up.
436
+
437
+ **Signal inventory after a failed session:** `[init] FAILED MCP servers: <names>` (names), `[mcp-init-error] server=<name> tail=…` (cause for each, from the platform's tail probe), `[mcp-spawn-tee-attached] server=<name> pid=<n>` (proof the wrapper attached), `[mcp-spawn-tee-exit] server=<name> code=<n>|signal=<s>` (proof the wrapper saw the exit), and optionally `[mcp:<name>] [<plugin>] …` from plugin-side sync-writes. Their union gives the investigator three independent sources for the same failure.
438
+
439
+ **Boot-smoke as publish-time gate.** The memory MCP carries `scripts/boot-smoke.sh` that spawns `dist/index.js` with stub env, sleeps 2s, asserts `kill -0 <pid>`, and reports `[boot-smoke] memory ok|FAILED tail=<n-lines>`. Wired to `prepublish` in `plugins/memory/mcp/package.json`. The pattern is propagatable to other plugin MCPs — it's deliberately not generalised yet because each plugin's stub-env requirements differ (memory needs ACCOUNT_ID + PLATFORM_ROOT + NEO4J_URI + SESSION_ID; others differ).
440
+
441
+ ---
442
+ # Install Overview
443
+ Source: https://docs.getmaxy.com/install.md
444
+
445
+ # Installing Maxy Code
446
+
447
+ 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).
448
+
449
+ | Host | Doc | Supervisor | Cloudflare tunnel | Hostname flag |
450
+ |---|---|---|---|---|
451
+ | 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 |
452
+ | 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 |
453
+ | 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 |
454
+
455
+ 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.
456
+
457
+ 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).
458
+
459
+ ---
460
+ # macOS Install
461
+ Source: https://docs.getmaxy.com/install/macos.md
462
+
463
+ # Installing Maxy Code on macOS
464
+
465
+ 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.
466
+
467
+ 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`.
468
+
469
+ > Pi install: see [pi.md](pi.md) for the Raspberry Pi flow.
470
+ > Other hosts and engineering detail: see [index.md](index.md) and [../deployment.md](../deployment.md).
471
+
472
+ ## Requirements
473
+
474
+ - 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`.
475
+ - 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.
476
+ - Admin (sudo) account. The installer asks for your password once when it sets the system hostname via `scutil`; everything else runs unprivileged.
477
+ - A working internet connection — Homebrew, npm, and Cloudflare endpoints are all reached during install.
478
+
479
+ ## 1. Install Node 22 via Homebrew
480
+
481
+ ```bash
482
+ # Homebrew (skip if already installed)
483
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
484
+
485
+ # Node 22 — pinned formula
486
+ brew install node@22
487
+ brew link --overwrite --force node@22
488
+
489
+ # Verify (must be 22.6 or newer)
490
+ node --version
491
+ ```
492
+
493
+ 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.
494
+
495
+ ## 2. Run the installer
496
+
497
+ The default brand is `maxy-code`. Run from any directory; the installer creates and writes everything under `$HOME/.maxy-code`.
498
+
499
+ ```bash
500
+ npx -y @rubytech/create-maxy-code@latest
501
+ ```
502
+
503
+ That command:
504
+ - creates the persist directory `$HOME/.maxy-code/` (logs, config, plugin state, the `.claude/` config tree, browser profile);
505
+ - 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);
506
+ - builds the platform payload bundled in the npm tarball;
507
+ - writes a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.maxy-code.plist` and loads it with `launchctl bootstrap gui/$UID`;
508
+ - prints the admin UI URL when the supervisor reports the server is listening.
509
+
510
+ The full install log lands at `$HOME/.maxy-code/logs/create-maxy-<timestamp>.log`. Every phase line is prefixed `[create-maxy] phase=… brand=… platform=darwin` — that's the canonical signal if you want to attach an install log to a support request.
511
+
512
+ ### Optional: `--hostname`
513
+
514
+ By default the installer leaves your existing macOS hostname alone and serves the admin UI at `http://<your-existing-LocalHostName>.local:<port>`. If you want a dedicated name on the LAN, pass `--hostname`:
515
+
516
+ ```bash
517
+ npx -y @rubytech/create-maxy-code@latest --hostname maxy
518
+ ```
519
+
520
+ That triggers three `sudo scutil --set` calls — `HostName`, `LocalHostName`, `ComputerName` — and the admin UI then resolves at `http://maxy.local:<port>` from any device on the same Bonjour/mDNS network. The flag is the only path that mutates system hostname state; omitting it preserves whatever you had.
521
+
522
+ ### Installing a second brand
523
+
524
+ To run, for example, `realagent-code` alongside the default install, repeat step 2 with that brand's package:
525
+
526
+ ```bash
527
+ npx -y @rubytech/create-realagent-code@latest
528
+ ```
529
+
530
+ The persist directory becomes `$HOME/.realagent-code`, the LaunchAgent becomes `com.rubytech.realagent-code`, and the admin URL switches to `http://realagent-code.local:<port>`. Every brand has its own isolated tree — there is no shared state, and `CLAUDE_CONFIG_DIR` is always `$HOME/.<brand>/.claude` for that brand, never the default `~/.claude`.
531
+
532
+ ## 3. Confirm the LaunchAgent is up
533
+
534
+ ```bash
535
+ launchctl print gui/$(id -u)/com.rubytech.maxy-code | head -20
536
+ ```
537
+
538
+ 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:
539
+
540
+ ```bash
541
+ cat ~/Library/LaunchAgents/com.rubytech.maxy-code.plist
542
+ ```
543
+
544
+ 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`.
545
+
546
+ ## 4. Open the admin UI
547
+
548
+ The install log's final block prints the URL. For the default brand on a default install:
549
+
550
+ ```
551
+ ================================================================
552
+
553
+ Open in your browser: http://<hostname>.local:<port>
554
+
555
+ ================================================================
556
+ ```
557
+
558
+ 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.
559
+
560
+ ## 5. Verify reboot persistence
561
+
562
+ 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.
563
+
564
+ If the admin UI does not respond after reboot:
565
+ - Re-check `launchctl print gui/$(id -u)/com.rubytech.maxy-code` for `state = running`.
566
+ - Tail the supervised log under `$HOME/.maxy-code/logs/`.
567
+ - 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.
568
+
569
+ ## Uninstall
570
+
571
+ ```bash
572
+ npx @rubytech/create-maxy-code --uninstall
573
+ ```
574
+
575
+ 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.
576
+
577
+ To uninstall a non-default brand, point at its package — for example:
578
+
579
+ ```bash
580
+ npx @rubytech/create-realagent-code --uninstall
581
+ ```
582
+
583
+ ## What this install does not do
584
+
585
+ macOS is the lightweight surface. Compared with the Pi install, the macOS path deliberately skips:
586
+
587
+ - **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.
588
+ - **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.
589
+ - **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.
590
+
591
+ ## Smoke checklist
592
+
593
+ The full operator-side fresh-Mac smoke is tracked separately (see `.tasks/339-macos-installer-smoke-task-297.md`). The headline pass criteria:
594
+
595
+ 1. Install on a clean account with no prior Maxy footprint completes and prints an admin URL.
596
+ 2. The admin UI opens at that URL and the chat surface is interactive.
597
+ 3. Reboot — the URL is reachable again after login without any manual action.
598
+ 4. Run `--hostname <name>` on a second install path; the URL switches to `<name>.local`.
599
+ 5. Uninstall removes the LaunchAgent, the plist, and the persist directory.
600
+
601
+ If any step fails, attach `$HOME/.<brand>/logs/create-maxy-<timestamp>.log` to the report.
602
+
603
+ ---
604
+ # Raspberry Pi Install
605
+ Source: https://docs.getmaxy.com/install/pi.md
606
+
607
+ # Installing Maxy Code on a Raspberry Pi
608
+
609
+ 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.
610
+
611
+ 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`.
612
+
613
+ > macOS install: see [macos.md](macos.md) for the laptop flow.
614
+ > Architecture notes for engineers: see [../deployment.md](../deployment.md).
615
+
616
+ ## Requirements
617
+
618
+ - Raspberry Pi 5, 16GB RAM (canonical) — Pi 4 8GB works but the first install runs slower.
619
+ - 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.
620
+ - 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).
621
+ - 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.
622
+ - A connected monitor or a working VNC viewer for the one-time `cloudflared tunnel login` step. After that step the Pi runs headless.
623
+
624
+ 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.
625
+
626
+ ## 1. Prepare the OS
627
+
628
+ 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.
629
+
630
+ ```bash
631
+ sudo apt-get update
632
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
633
+ sudo apt-get install -y nodejs
634
+
635
+ # Verify (must be 22.6 or newer)
636
+ node --version
637
+ ```
638
+
639
+ 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.
640
+
641
+ ## 2. Run the installer
642
+
643
+ 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.
644
+
645
+ ```bash
646
+ npx -y @rubytech/create-maxy-code@latest --hostname <hostname>
647
+ ```
648
+
649
+ Pick a `<hostname>` that is short, lowercase, and unique across your Cloudflare account (e.g. `maxy-alice`). The installer sets `HostName`, `LocalHostName`, and the Avahi `host-name` to this value, then registers a systemd user-service named `<hostname>.service` that owns the platform process.
650
+
651
+ That command:
652
+
653
+ - creates the persist directory `$HOME/.maxy-code/` (logs, config, plugin state, the `.claude/` config tree, browser profile);
654
+ - 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);
655
+ - `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`;
656
+ - 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;
657
+ - builds the platform payload bundled in the npm tarball;
658
+ - writes a systemd user-service at `~/.config/systemd/user/<hostname>.service` and enables it with `systemctl --user enable --now`;
659
+ - prints the LAN URL `http://<hostname>.local:<port>` when the supervisor reports the server is listening. The Cloudflare-fronted public URL is not provisioned at install time — step 4 below.
660
+
661
+ The full install log lands at `$HOME/.maxy-code/logs/install-<timestamp>.log`. Every phase line is prefixed `[create-maxy] phase=… brand=… platform=linux` — that is the canonical signal if you want to attach an install log to a support request.
662
+
663
+ 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.
664
+
665
+ ### Installing a second brand
666
+
667
+ 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:
668
+
669
+ ```bash
670
+ npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
671
+ ```
672
+
673
+ The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned as a dedicated `neo4j-<realagent-hostname>` service on its own port, and the VNC display + websockify + ttyd ports all shift to the brand's reserved range. There is no shared state; `CLAUDE_CONFIG_DIR` is always `$HOME/.<brand>/.claude` for that brand, never the default `~/.claude`.
674
+
675
+ ## 3. Confirm the systemd user-service is up
676
+
677
+ ```bash
678
+ systemctl --user status <hostname>.service
679
+ ```
680
+
681
+ You should see `Active: active (running)`. If the unit is in `failed` or `activating` state, tail the supervised journal:
682
+
683
+ ```bash
684
+ journalctl --user -u <hostname>.service -n 200 --no-pager
685
+ ```
686
+
687
+ 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.
688
+
689
+ The installer also wires `loginctl enable-linger <user>` so the user-service survives logout. If `loginctl show-user <user> | grep Linger` does not return `Linger=yes`, re-run the installer or `sudo loginctl enable-linger <user>` by hand — without linger the service stops when you log out of the Pi.
690
+
691
+ ## 4. Bootstrap the Cloudflare tunnel
692
+
693
+ 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.
694
+
695
+ Open the Pi's VNC browser at `http://<hostname>.local:<port>/vnc` (or over the LAN at whichever port the install log printed for noVNC). In the chat surface, ask the agent to run the Cloudflare setup — the [`cloudflare`](../../platform/plugins/cloudflare/PLUGIN.md) plugin's `setup-tunnel` skill walks `cloudflared tunnel login`, `cloudflared tunnel create`, `cloudflared tunnel route dns`, and the systemd `<hostname>-cloudflared.service` unit in order, streaming `cloudflared`'s stdout verbatim into chat. The OAuth URL the CLI prints is linkified by the PTY; the operator clicks it inside the VNC browser and authorises the cert against the right Cloudflare account.
696
+
697
+ Setup is done when, and only when, `curl -I https://<hostname>.<your-zone>` issued from outside the local network returns `HTTP/2 200`. No state file, no `tunnel run` exit code, and no "service is active" claim substitutes for the live HTTPS response.
698
+
699
+ ## 5. Open the admin UI
700
+
701
+ After step 4 the public URL is your Cloudflare-fronted hostname. Open it in any browser, sign in, and the admin UI loads.
702
+
703
+ On the LAN (or pre-tunnel), the URL is `http://<hostname>.local:<port>` — the install log's final block prints both addresses:
704
+
705
+ ```
706
+ ================================================================
707
+
708
+ Open in your browser: http://<hostname>.local:<port>
709
+ Public URL (after Cloudflare setup): https://<hostname>.<your-zone>
710
+
711
+ ================================================================
712
+ ```
713
+
714
+ ## 6. Verify reboot persistence
715
+
716
+ 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.
717
+
718
+ If the admin UI does not respond after reboot:
719
+
720
+ - `systemctl --user status <hostname>.service` — confirm `active (running)`.
721
+ - `journalctl --user -u <hostname>.service -n 200 --no-pager` — tail the supervisor log.
722
+ - `loginctl show-user <user> | grep Linger` — confirm `Linger=yes`. Without it the user-service does not start until you SSH in.
723
+ - `systemctl --user status <hostname>-cloudflared.service` — confirm the tunnel is up. The platform unit can be healthy while the tunnel is not, in which case the LAN URL works and the public URL does not.
724
+
725
+ ## Uninstall
726
+
727
+ ```bash
728
+ npx -y @rubytech/create-maxy-code@latest --uninstall
729
+ ```
730
+
731
+ 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.
732
+
733
+ To uninstall a non-default brand, point at its package — for example:
734
+
735
+ ```bash
736
+ npx -y @rubytech/create-realagent-code@latest --uninstall
737
+ ```
738
+
739
+ ## What this install does not do
740
+
741
+ - **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.
742
+ - **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.
743
+ - **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.
744
+
745
+ ## Smoke checklist
746
+
747
+ Fresh-Pi smoke pass criteria:
748
+
749
+ 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)`.
750
+ 2. The LAN URL `http://<hostname>.local:<port>` opens the admin UI and the chat surface is interactive.
751
+ 3. Cloudflare setup driven by the `cloudflare` plugin's `setup-tunnel` skill ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from outside the LAN.
752
+ 4. Reboot — both URLs are reachable again after boot without any manual action.
753
+ 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.
754
+ 6. Uninstall removes the systemd unit, the Avahi service file, and the persist directory.
755
+
756
+ If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.
757
+
758
+ ---
759
+ # Hetzner Cloud Install
760
+ Source: https://docs.getmaxy.com/install/hetzner.md
761
+
762
+ # Installing Maxy Code on a Hetzner Cloud server
763
+
764
+ 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.
765
+
766
+ 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`.
767
+
768
+ > Pi install: see [pi.md](pi.md). macOS install: see [macos.md](macos.md). Architecture notes for engineers: see [../deployment.md](../deployment.md).
769
+
770
+ > **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.
771
+
772
+ ## Server spec
773
+
774
+ | Field | Value | Why |
775
+ |---|---|---|
776
+ | 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. |
777
+ | 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. |
778
+ | Location | Nearest to the operator (Falkenstein, Nuremberg, Helsinki, Hillsboro, Singapore) | Latency to the admin browser; choice does not affect the install. |
779
+ | Network | IPv4 + IPv6 | The Cloudflare tunnel terminates all public traffic; the server's own IPv4 is not exposed to operators after step 4. |
780
+ | Firewall | SSH (22) inbound only | Every other inbound surface is fronted by the Cloudflare tunnel, which dials *out* to Cloudflare. |
781
+ | SSH key | Added at provision time | Hetzner does not enable password SSH on the default Ubuntu image when an SSH key is attached. |
782
+
783
+ A CAX11 or CAX21 cannot run the platform. The Pi 16GB is the floor; CAX31 is the like-for-like Hetzner equivalent.
784
+
785
+ ## 1. Provision the server
786
+
787
+ In the [Hetzner Cloud console](https://console.hetzner.cloud):
788
+
789
+ 1. Create a project (or use an existing one).
790
+ 2. Add server → **Location**: nearest region → **Image**: Ubuntu 24.04 → **Type**: Arm64 → **CAX31**.
791
+ 3. Add your SSH key under **SSH keys** (or paste it inline). Skip the cloud-init / user-data field.
792
+ 4. Name the server (e.g. `maxy-alice`) and create it.
793
+
794
+ When the server reaches `Running`, copy its public IPv4. SSH in as `root`:
795
+
796
+ ```bash
797
+ ssh root@<ipv4>
798
+ ```
799
+
800
+ ## 2. Prepare the OS
801
+
802
+ 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.
803
+
804
+ ```bash
805
+ apt-get update
806
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
807
+ apt-get install -y nodejs
808
+
809
+ # Verify (must be 22.6 or newer)
810
+ node --version
811
+ ```
812
+
813
+ 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).
814
+
815
+ ```bash
816
+ adduser --disabled-password --gecos "" admin
817
+ usermod -aG sudo admin
818
+ echo "admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/admin
819
+ chmod 440 /etc/sudoers.d/admin
820
+ mkdir -p /home/admin/.ssh
821
+ cp ~/.ssh/authorized_keys /home/admin/.ssh/authorized_keys
822
+ chown -R admin:admin /home/admin/.ssh
823
+ chmod 700 /home/admin/.ssh
824
+ chmod 600 /home/admin/.ssh/authorized_keys
825
+
826
+ # From now on, SSH as admin, not root
827
+ ssh admin@<ipv4>
828
+ ```
829
+
830
+ 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.
831
+
832
+ ## 3. Run the installer
833
+
834
+ 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.
835
+
836
+ ```bash
837
+ npx -y @rubytech/create-maxy-code@latest --hostname <hostname>
838
+ ```
839
+
840
+ Pick a `<hostname>` that is short, lowercase, and unique across your Cloudflare account (e.g. `maxy-alice`). The installer:
841
+
842
+ - creates the persist directory `$HOME/.maxy-code/` (logs, config, plugin state, `.claude/` config tree, browser profile);
843
+ - exports `CLAUDE_CONFIG_DIR=$HOME/.maxy-code/.claude` for every Claude Code invocation;
844
+ - `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`;
845
+ - swaps a snap-Chromium for a deb-packaged Chromium when the Ubuntu image ships Chromium as a snap;
846
+ - builds the platform payload bundled in the npm tarball;
847
+ - writes a systemd user-service at `~/.config/systemd/user/<hostname>.service` and enables it with `systemctl --user enable --now`;
848
+ - prints the loopback URL `http://localhost:<port>` when the supervisor reports the server is listening.
849
+
850
+ The full install log lands at `$HOME/.maxy-code/logs/install-<timestamp>.log`.
851
+
852
+ ### Installing a second brand
853
+
854
+ Repeat step 3 with the other brand's package and a different hostname:
855
+
856
+ ```bash
857
+ npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
858
+ ```
859
+
860
+ The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned on its own port, and the VNC display + websockify + ttyd ports shift to the brand's reserved range.
861
+
862
+ ## 4. Reach the dashboard and VNC browser over SSH port-forwarding
863
+
864
+ 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.
865
+
866
+ All `ssh -L` commands in this step are run on **your local machine** — the machine you SSH from, not on the Hetzner server.
867
+
868
+ 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:
869
+
870
+ ```bash
871
+ ssh -L 19200:localhost:19200 -L 6080:localhost:6080 admin@<ipv4> # maxy-code
872
+ # or
873
+ ssh -L 19200:localhost:19200 -L 6081:localhost:6081 admin@<ipv4> # realagent-code
874
+ ```
875
+
876
+ While that session is open:
877
+ - `http://localhost:19200` — dashboard
878
+ - `http://localhost:6080/vnc.html` — VNC browser (Claude's OAuth and Cloudflare setup run here)
879
+
880
+ 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:
881
+
882
+ ```bash
883
+ # One terminal per server, on your local machine
884
+ ssh -L 19200:localhost:19200 -L 6080:localhost:6080 admin@server1
885
+ ssh -L 19201:localhost:19200 -L 6081:localhost:6080 admin@server2
886
+ ssh -L 19202:localhost:19200 -L 6082:localhost:6080 admin@server3
887
+ ```
888
+
889
+ After the Cloudflare tunnel is provisioned, close the SSH session — every surface is reachable at the public hostname.
890
+
891
+ ## 5. Bootstrap the Cloudflare tunnel
892
+
893
+ 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.
894
+
895
+ In the noVNC browser session, open the admin UI at `http://localhost:<port>`. In chat, ask the agent to run the Cloudflare setup — the [`cloudflare`](../../platform/plugins/cloudflare/PLUGIN.md) plugin's `setup-tunnel` skill walks `cloudflared tunnel login`, `cloudflared tunnel create`, `cloudflared tunnel route dns`, and the systemd `<hostname>-cloudflared.service` unit in order, streaming `cloudflared`'s stdout verbatim into chat. The OAuth URL the CLI prints is linkified by the PTY; the operator clicks it inside the noVNC browser and authorises the cert against the right Cloudflare account.
896
+
897
+ Setup is done when, and only when, `curl -I https://<hostname>.<your-zone>` issued from the operator's laptop returns `HTTP/2 200`. No state file, no `tunnel run` exit code, and no "service is active" claim substitutes for the live HTTPS response.
898
+
899
+ The SSH port-forward from step 4 can be closed after this point.
900
+
901
+ ## 6. Open the admin UI
902
+
903
+ 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.
904
+
905
+ The Hetzner server's IPv4 is not advertised anywhere; the only public surface is the Cloudflare hostname. If the operator's laptop is offline, the loopback URL inside an SSH session (`http://localhost:<port>` over `ssh -L`) still works.
906
+
907
+ ## 7. Verify reboot persistence
908
+
909
+ Reboot the server (`sudo reboot`). After it comes back up, SSH back in and confirm:
910
+
911
+ ```bash
912
+ systemctl --user status <hostname>.service
913
+ systemctl --user status <hostname>-cloudflared.service
914
+ ```
915
+
916
+ 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`.
917
+
918
+ Open the public URL from outside the server's network and confirm the admin UI is reachable without any manual action.
919
+
920
+ ## Uninstall
921
+
922
+ ```bash
923
+ npx -y @rubytech/create-maxy-code@latest --uninstall
924
+ ```
925
+
926
+ 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.
927
+
928
+ To uninstall a non-default brand, point at its package:
929
+
930
+ ```bash
931
+ npx -y @rubytech/create-realagent-code@latest --uninstall
932
+ ```
933
+
934
+ ## What this install does not do
935
+
936
+ - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
937
+ - **No Cloudflare API tokens.** The only Cloudflare auth path is `cloudflared tunnel login` in the noVNC browser over SSH forward.
938
+ - **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.
939
+ - **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel.
940
+
941
+ ## Smoke checklist
942
+
943
+ Fresh-Hetzner smoke pass criteria:
944
+
945
+ 1. Provision a CAX31 with Ubuntu 24.04 arm64 and an SSH key; SSH in as `root`, create `admin`, switch.
946
+ 2. Install completes on the clean image, prints a loopback URL, and the systemd user-service is `active (running)`.
947
+ 3. The noVNC page reached over `ssh -L 8080:localhost:<novnc-port>` displays the admin UI.
948
+ 4. Cloudflare setup driven by the `cloudflare` plugin's `setup-tunnel` skill ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
949
+ 5. Reboot the server; both `<hostname>.service` and `<hostname>-cloudflared.service` come back up; the public URL is reachable again without any manual action.
950
+ 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.
951
+ 7. Uninstall removes the systemd unit and the persist directory.
952
+
953
+ If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.
954
+
955
+ ---
956
+ # Cloudflare Tunnel
957
+ Source: https://docs.getmaxy.com/cloudflare.md
958
+
959
+ # Cloudflare Tunnel — the dashboard is the source of truth
960
+
961
+ 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.
962
+
963
+ ## Identity model
964
+
965
+ | Concept | Source |
966
+ |------|--------|
967
+ | **Product identity** (Maxy vs Real Agent) | `brand.json` (`productName`, `configDir`) — known at install. |
968
+ | **Cloudflare account identity** | `cert.pem` from OAuth. One account per brand per device. |
969
+ | **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. |
970
+ | **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `alias-domains.json`. |
971
+
972
+ 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.
973
+
974
+ ## Setup flow
975
+
976
+ 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:
977
+
978
+ - **Admin address** — the hostname that will serve the admin chat (e.g. `admin.yourdomain.com`).
979
+ - **Public address** — optional hostname for the public agent (e.g. `public.yourdomain.com` or `chat.yourdomain.com`).
980
+ - **Proxy apex** — optional bare-domain hostname (e.g. `yourdomain.com`) that should also serve the public agent.
981
+ - **Admin password** — the password used to gate remote access to the admin surface.
982
+
983
+ 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.
984
+
985
+ 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.
986
+
987
+ The setup-done claim only fires after the agent runs `curl -I https://<admin-hostname>` from outside the local network and the response shows a `200` line. That HTTP response is the only success terminal.
988
+
989
+ ## Getting a domain on Cloudflare
990
+
991
+ The tunnel needs a domain on the Cloudflare account the device will sign into. Two paths, both in your browser:
992
+
993
+ **Option A: Buy a new domain through Cloudflare.** Navigate to cloudflare.com → Domains and buy one. Cloudflare sets everything up.
994
+
995
+ **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.
996
+
997
+ Existing website traffic continues to work during and after the switch. Only DNS resolution changes owners.
998
+
999
+ ## Reset / account switch
1000
+
1001
+ Ask the agent to reset Cloudflare. The agent executes the reset flow from `plugins/cloudflare/references/reset-guide.md`:
1002
+
1003
+ - Deletes every tunnel on the brand's current Cloudflare account (via the bound cert).
1004
+ - Wipes the brand's `${CFG_DIR}`.
1005
+ - Stops the brand's cloudflared user service.
1006
+
1007
+ 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.
1008
+
1009
+ After reset, run setup again. The fresh `cloudflared tunnel login` will pick whichever Cloudflare account you sign into.
1010
+
1011
+ ## Manual runbook
1012
+
1013
+ 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.
1014
+
1015
+ ## Dashboard operations the CLI cannot do
1016
+
1017
+ 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.
1018
+
1019
+ ## Troubleshooting
1020
+
1021
+ ### Tunnel won't start
1022
+
1023
+ Ask the agent to check. The agent reads `systemctl --user status ${BRAND}-cloudflared.service` and the cloudflared log under `~/{configDir}/cloudflared/`. Common states:
1024
+
1025
+ - **No cloudflared process running** — the cloudflared service exited or never started. The agent runs the manual-setup flow to re-issue tunnel creation.
1026
+ - **`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.
1027
+
1028
+ ### URL returns 530
1029
+
1030
+ DNS propagation or account mismatch. Wait 30–60 seconds and retry first. If the 530 persists:
1031
+
1032
+ - 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.
1033
+ - The UDP buffer for QUIC may be undersized on this device — check the cloudflared log for `failed to sufficiently increase receive buffer size`.
1034
+
1035
+ ### URL returns connection refused
1036
+
1037
+ The tunnel is live but nothing is listening on the platform port. Start the platform service: `systemctl --user start ${BRAND}.service`.
1038
+
1039
+ ### Admin hostname serves the public agent
1040
+
1041
+ `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.
1042
+
1043
+ 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.
1044
+
1045
+ ### DNS not resolving
1046
+
1047
+ 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.
1048
+
1049
+ ### Remote login issues
1050
+
1051
+ - 5 failed login attempts → 15-minute lockout — wait for expiry.
1052
+ - 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.
1053
+
1054
+ ## What the agent does and does not do
1055
+
1056
+ **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.
1057
+
1058
+ **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.
1059
+
1060
+ When a command fails, the agent reports the failure and cites the relevant recovery step. It does not improvise.
1061
+
1062
+ ---
1063
+ # Access Control
1064
+ Source: https://docs.getmaxy.com/access-control.md
1065
+
1066
+ # Access Control
1067
+
1068
+ ## What It Is
1069
+
1070
+ 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.
1071
+
1072
+ ## Access Modes
1073
+
1074
+ Each public agent has one of two access modes:
1075
+
1076
+ | Mode | Who can chat | What the agent remembers |
1077
+ |------|--------------|--------------------------|
1078
+ | Open (default) | Anyone with the URL. No login required. | Public-scope knowledge only. Nothing per-visitor. |
1079
+ | 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. |
1080
+
1081
+ ## How to Set It Up
1082
+
1083
+ Tell Maxy: "Set my public agent to gated access" or "Make the coaching agent invitation-only."
1084
+
1085
+ Maxy flips the agent's access mode. The next visitor to your public URL sees a sign-in screen instead of the chat.
1086
+
1087
+ ## Inviting Visitors
1088
+
1089
+ Tell Maxy: "Invite sarah@client.co to the coaching agent."
1090
+
1091
+ 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.
1092
+
1093
+ Only email invitations are supported. Phone, OTP, and password flows are not part of the current build.
1094
+
1095
+ ## What Visitors Experience
1096
+
1097
+ - **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.
1098
+ - **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.
1099
+ - **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.
1100
+ - **Revoked or expired:** Their next request is bounced back to the sign-in screen. They cannot get past it until you re-invite them.
1101
+
1102
+ ## Per-Visitor Memory
1103
+
1104
+ 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.
1105
+
1106
+ 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.
1107
+
1108
+ 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.
1109
+
1110
+ ## Managing Access
1111
+
1112
+ All access management is done through conversation with Maxy:
1113
+
1114
+ - "Who has access to my coaching agent?" — lists active visitors and their `sliceToken`.
1115
+ - "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.
1116
+ - "Extend Tom's access by 30 days" — pushes the expiry date forward. Slice unchanged.
1117
+ - "Resend Sarah's invitation" — generates a fresh magic link and emails it. The slice stays the same, so her existing memory carries over.
1118
+
1119
+ 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.
1120
+
1121
+ ## Visitor Identity
1122
+
1123
+ 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.
1124
+
1125
+ ## Action Approval
1126
+
1127
+ 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.
1128
+
1129
+ 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:
1130
+
1131
+ - "Approve it" — Maxy executes the action immediately
1132
+ - "Reject it" — the action is cancelled
1133
+ - "Change the subject to X" — Maxy modifies the action and executes the edited version
1134
+
1135
+ Internal operations (creating tasks, updating contacts, searching memory) execute automatically without approval.
1136
+
1137
+ ### Changing the Policy
1138
+
1139
+ Tell Maxy to change which actions require approval:
1140
+
1141
+ - "Auto-send follow-up emails from now on" — emails execute without approval
1142
+ - "Require approval for all WhatsApp messages" — restores the default gating
1143
+ - "What actions currently require my approval?" — lists the current policy
1144
+
1145
+ Changes are per-account and take effect immediately.
1146
+
1147
+ ## Filesystem Access (SMB Share)
1148
+
1149
+ 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.
1150
+
1151
+ ---
1152
+ # Settings
1153
+ Source: https://docs.getmaxy.com/settings.md
1154
+
1155
+ # Settings
1156
+
1157
+ ## Output Style
1158
+
1159
+ Controls how Maxy communicates with you.
1160
+
1161
+ | Style | Behaviour |
1162
+ |-------|-----------|
1163
+ | `default` | Concise, direct responses — gets to the point |
1164
+ | `explanatory` | More detailed responses with educational context — explains reasoning and trade-offs |
1165
+
1166
+ **Changing output style:** Tell Maxy "Switch to explanatory mode" or "Use default output style."
1167
+
1168
+ Changes take effect on the next session. The current session continues with the existing style.
1169
+
1170
+ ## Effort Level
1171
+
1172
+ Controls how much work Maxy puts into each task — specifically, how many steps it takes before stopping and checking with you.
1173
+
1174
+ | Level | Max turns | Use when |
1175
+ |-------|-----------|----------|
1176
+ | `low` | 5 | Quick questions, simple lookups |
1177
+ | `medium` | 10 | Standard tasks — most daily use |
1178
+ | `high` | 20 | Complex multi-step tasks |
1179
+ | `auto` | 20 | Let Maxy decide (same ceiling as high) |
1180
+ | `max` | 40 | Long autonomous workflows |
1181
+
1182
+ **Changing effort level:** Tell Maxy "Set effort to high" or "Use low effort mode."
1183
+
1184
+ Changes take effect on the next session.
1185
+
1186
+ ## Thinking View
1187
+
1188
+ Controls how Maxy's thinking process is displayed in the chat.
1189
+
1190
+ | Mode | Behaviour |
1191
+ |------|-----------|
1192
+ | `default` | Thinking steps shown expanded, tool use collapsed |
1193
+ | `expanded` | Everything shown expanded — thinking, tool use, and results |
1194
+ | `collapsed` | Everything collapsed — compact view, expand on tap |
1195
+
1196
+ **Changing thinking view:** Tell Maxy "Show thinking by default", "Show everything expanded", or "Hide thinking."
1197
+
1198
+ Changes take effect on the next session.
1199
+
1200
+ ## Viewing Current Settings
1201
+
1202
+ Ask Maxy: "What are my current settings?" or "What output style am I using?"
1203
+
1204
+ ## Default Agent
1205
+
1206
+ Controls which public agent serves the root URL (`/`). Visitors who go to your public site without specifying an agent slug see this agent.
1207
+
1208
+ **Changing the default agent:** Tell Maxy "Make sales the default agent" or "Set the default to support."
1209
+
1210
+ The change takes effect on the next page load. The previous default agent remains accessible at its `/{slug}` URL.
1211
+
1212
+ ## Account Preferences
1213
+
1214
+ You can ask Maxy to show or change any of the following:
1215
+
1216
+ - Default agent (which public agent serves the root URL)
1217
+ - Admin model (which Claude model powers the admin agent)
1218
+ - Public model (which Claude model powers the public agent)
1219
+ - Output style
1220
+ - Effort level
1221
+ - Context mode
1222
+ - Enabled plugins
1223
+
1224
+ Tell Maxy what you want to change and it handles the rest.
1225
+
1226
+ ## PIN
1227
+
1228
+ Your admin PIN is set during initial setup. To change it, ask Maxy: "Change my admin PIN."
1229
+
1230
+ Maxy will ask for your current PIN to verify, then set the new one.
1231
+
1232
+ ## Adding admins
1233
+
1234
+ 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.
1235
+
1236
+ 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.
1237
+
1238
+ ---
1239
+ # Contacts
1240
+ Source: https://docs.getmaxy.com/contacts-guide.md
1241
+
1242
+ # Contacts Guide
1243
+
1244
+ ## What a Contact Is
1245
+
1246
+ 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.
1247
+
1248
+ ## Adding a Contact
1249
+
1250
+ Tell Maxy naturally:
1251
+
1252
+ - "Add John Smith to my contacts — he's a potential client I met at the conference"
1253
+ - "Create a contact for sarah@acme.com, her name is Sarah Chen, she's the head of procurement at Acme"
1254
+ - "Add Hazel to contacts, phone +27747309676, she's a virtual assistant"
1255
+
1256
+ Maxy will extract the details and confirm the record before saving.
1257
+
1258
+ Required: first name and at least one of email or phone number. Everything else is optional but useful.
1259
+
1260
+ ## Looking Up a Contact
1261
+
1262
+ Ask naturally:
1263
+
1264
+ - "What do you know about John Smith?"
1265
+ - "Look up Sarah Chen"
1266
+ - "Find the contact from Acme procurement"
1267
+ - "Look up +27747309676"
1268
+
1269
+ Maxy searches by name, email, phone number, or any detail you provide.
1270
+
1271
+ ## Updating a Contact
1272
+
1273
+ Tell Maxy what changed:
1274
+
1275
+ - "Update John Smith's email to john@newcompany.com"
1276
+ - "Add a note to Sarah Chen's record: prefers evening calls"
1277
+ - "John Smith is now at Horizon Capital, not Acme"
1278
+
1279
+ ## Listing Contacts
1280
+
1281
+ - "List all my contacts"
1282
+ - "Show me everyone from Acme"
1283
+ - "Who are my contacts in fintech?"
1284
+
1285
+ ## Deleting a Contact
1286
+
1287
+ To remove a single contact from the graph:
1288
+
1289
+ - "Delete Dan from my contacts"
1290
+ - "Remove the duplicate contact for Sarah Chen"
1291
+ - "Delete the contact with email dan@example.com"
1292
+
1293
+ 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.
1294
+
1295
+ 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.
1296
+
1297
+ ## Exporting Contact Data (GDPR Subject Access)
1298
+
1299
+ When a person requests a copy of all data held about them, ask Maxy:
1300
+
1301
+ - "Export all data we hold on john@example.com"
1302
+ - "Show me everything we know about +447700900123"
1303
+
1304
+ 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.
1305
+
1306
+ ## Erasing Contact Data (GDPR Right to Erasure)
1307
+
1308
+ When a person requests deletion of all their data, ask Maxy:
1309
+
1310
+ - "Delete all data we hold on john@example.com"
1311
+ - "Erase everything for Sarah Chen"
1312
+
1313
+ Maxy first shows a preview of what would be deleted (counts per data type). Confirm the deletion to proceed. The erasure cascade covers:
1314
+
1315
+ - The Person record itself
1316
+ - All access credentials (AccessGrant nodes)
1317
+ - Conversations and messages attributed to the contact
1318
+ - Emails sent to or from the contact's email address
1319
+
1320
+ The deletion is permanent and irreversible. A receipt is returned listing exactly what was removed.
1321
+
1322
+ Note: server logs may contain residual references to the contact's identifiers. Manual log review is recommended for complete erasure.
1323
+
1324
+ ## Stored Fields
1325
+
1326
+ | Field | Description |
1327
+ |-------|-------------|
1328
+ | `givenName` | First name (required) |
1329
+ | `familyName` | Last name (optional) |
1330
+ | `email` | Email address (identifier — at least one of email or telephone required; used to deduplicate) |
1331
+ | `telephone` | Phone number (identifier — at least one of email or telephone required; used to deduplicate) |
1332
+ | `jobTitle` | Job title or role |
1333
+ | `source` | Where this contact came from (e.g. "public.maxy.bot", "telegram", "manual") |
1334
+ | `status` | Contact status (e.g. "active", "prospect", "booked") |
1335
+ | `createdOn` | When the record was created |
1336
+
1337
+ ---
1338
+ # Memory
1339
+ Source: https://docs.getmaxy.com/memory-guide.md
1340
+
1341
+ # Memory Guide
1342
+
1343
+ ## Brain-first lookup
1344
+
1345
+ 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).
1346
+
1347
+ ## How Memory Works
1348
+
1349
+ 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.
1350
+
1351
+ 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.
1352
+
1353
+ The graph lives entirely on your hardware. Nothing is sent to the cloud.
1354
+
1355
+ ## What Gets Remembered
1356
+
1357
+ Maxy stores:
1358
+
1359
+ - **Contacts** — people, companies, relationships between them
1360
+ - **Conversations** — key decisions, commitments, follow-ups mentioned in chat
1361
+ - **Preferences** — things you've told Maxy about how you like to work
1362
+ - **Context** — project status, ongoing threads, background you've shared
1363
+
1364
+ Maxy remembers details you mention naturally: "I'm meeting with Sarah on Thursday" creates a memory that Thursday has a meeting with Sarah.
1365
+
1366
+ ## Telling Maxy to Remember Something
1367
+
1368
+ Just say it naturally:
1369
+
1370
+ - "Remember that I prefer morning calls"
1371
+ - "Note that the Johnson account is on hold until March"
1372
+ - "My wife's name is Emma, keep that in mind"
1373
+
1374
+ Maxy will confirm and store it.
1375
+
1376
+ ## How Maxy learns how you work
1377
+
1378
+ 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.
1379
+
1380
+ ## Telling Maxy to Forget Something
1381
+
1382
+ Be direct:
1383
+
1384
+ - "Forget everything about the Johnson account"
1385
+ - "Remove Sarah's contact record"
1386
+ - "Clear what you know about my pricing preferences"
1387
+ - "Delete that pricing guide I uploaded"
1388
+
1389
+ 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.
1390
+
1391
+ ## Managing Documents
1392
+
1393
+ ### Listing files
1394
+
1395
+ Ask: "What files do I have stored?" or "List my attachments"
1396
+
1397
+ Maxy shows all uploaded files with their ingestion status — whether they've been processed into the knowledge graph.
1398
+
1399
+ 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.
1400
+
1401
+ ### Reading files
1402
+
1403
+ Ask: "Show me what's in the pricing guide" or "Read the quarterly report"
1404
+
1405
+ Maxy returns the full content of text and markdown files, extracted text from PDFs, and metadata for images.
1406
+
1407
+ ### Editing files
1408
+
1409
+ Ask: "Update the pricing in that document" or "Change the introduction paragraph"
1410
+
1411
+ 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.
1412
+
1413
+ ### Renaming files
1414
+
1415
+ Ask: "Rename that file to quarterly-report-q1.pdf"
1416
+
1417
+ Maxy updates the filename in both the stored metadata and the knowledge graph.
1418
+
1419
+ ### Deleting documents
1420
+
1421
+ Ask: "Delete the old pricing guide"
1422
+
1423
+ 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".
1424
+
1425
+ ## Searching Memory
1426
+
1427
+ Ask naturally:
1428
+
1429
+ - "What do you know about Tom Henderson?"
1430
+ - "What did I last discuss about the Acme proposal?"
1431
+ - "Who have I met from the fintech conference?"
1432
+
1433
+ ## Listing and counting
1434
+
1435
+ 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".
1436
+
1437
+ 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.
1438
+
1439
+ 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.
1440
+
1441
+ **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.
1442
+
1443
+ **Delete a node:** drag it to the trash icon top-right of the canvas. No confirmation — deletes are reversible for 30 days. To restore, toggle **Show trashed** inside the filter menu and click **Restore** on the node, or ask Maxy in chat ("restore the <label> I just deleted"). Deleting a conversation also trashes its messages in the same step, so they reappear together on restore.
1444
+
1445
+ **Bulk cleanup of conversations in chat:** when you ask Maxy to clean up conversations in bulk ("trash all empty conversations," "clean up the single-assistant tests"), the agent uses a deterministic selector with a fixed set of filter names — it cannot author custom delete queries. The server re-runs the same filter on every candidate before it trashes, so a stale list can't destroy something the filter wouldn't match now. If the filter matches nothing, Maxy reports "no candidates" and nothing happens.
1446
+
1447
+ The page reads only your own brand's Neo4j — a Maxy device and a Real Agent device share no graph state even when on the same laptop. No credentials are required; the view inherits your admin session.
1448
+
1449
+ **Typo-proof cypher.** When Maxy runs direct Cypher to answer a relational question, the query is checked against your Neo4j's live label and relationship-type taxonomy before it executes. Cypher that references an unknown name (an edge or label that does not exist in your graph) is rejected for writes and flagged with a warnings header for reads, so Maxy never silently acts on a query that targeted the wrong set of nodes. You should not see this — it runs invisibly — but it is the safety net that stops a fabricated edge name from producing "empty" results that are really just unreachable. Before acting on a bulk operation Maxy surfaces the result count and a sample; if it ever describes a cypher rejection, that means its first attempt was malformed and it corrected itself.
1450
+
1451
+ ## Write doctrine
1452
+
1453
+ Every new node in Maxy's graph is created with at least one connection to an existing node. A contact connects to the conversation or organisation it came from; a task connects to the session that raised it or the entities it will affect; a session summary connects to the conversation it summarises. A node with no connection is noise — it cannot be attributed, traversed, or explained — so the graph refuses to create one. If Maxy ever tries to record something without a link, the write is rejected and Maxy asks you to clarify where it belongs.
1454
+
1455
+ Every node also carries a provenance stamp — which agent wrote it, in which session, via which tool. You never see these fields, but they are how operators trace unusual growth back to the code path that produced it, and why your graph stays clean over time.
1456
+
1457
+ **Two write surfaces, one substrate.** General agents write through schema-aware helpers — Maxy can record a new contact, a new commitment, a new preference without ever typing a database query, and the helper enforces the connection-and-provenance rule above structurally. The graph-steward role (the specialist Maxy dispatches when you ask for graph hygiene — "merge those two duplicate contacts," "wire those four tasks to the meeting," "rename the legacy label across the graph") additionally has a raw Cypher write tool for the multi-step operations the helpers cannot express. The steward role internalises the same connection-and-provenance discipline in its prompt; a post-write audit emits a warning on every breach so the same rules apply to both surfaces. Both paths feed the same hourly orphan trend and the same forensic provenance fields — read-side, you cannot tell the two apart, and that is the point.
1458
+
1459
+ ## Public-facing summaries for customer-readable subjects
1460
+
1461
+ Some entities in your graph are knowable by people outside your team — companies you work with, projects you've delivered, the business itself. For those entities (Maxy treats `:Organization`, `:Concept`, `:Project`, and `:LocalBusiness` this way), Maxy maintains two summaries: a private one only you and your specialist agents see, and a customer-facing public one your public agents are allowed to surface.
1462
+
1463
+ Whenever Maxy updates the private summary on one of these entities, it automatically rewrites the public summary in the same step using a separate prompt that strips operator-voice ("needs follow-up", "action: chase next week"), internal sentiment, and anything that reads like a note-to-self. The two summaries stay in lockstep without you doing anything.
1464
+
1465
+ If you want to write the public summary yourself — for instance, because the auto-generated version misses something you want customers to see — just tell Maxy the wording you want for the public summary on that entity, and Maxy will write it directly. It stays locked in for seven days; after that, the next automatic refresh can take over again, unless you re-pin it.
1466
+
1467
+ People entries (`:Person`) are deliberately excluded from this dual-summary system. Notes about contacts are private by definition and never get a public-facing form.
1468
+
1469
+ ## Privacy
1470
+
1471
+ All memory is stored on your local Raspberry Pi. The Neo4j database never leaves your network. Maxy does not sync memory to any cloud service or third party.
1472
+
1473
+ If you want to wipe everything and start fresh, ask: "Reset my memory graph." Maxy will ask for confirmation before doing so.
1474
+
1475
+ ---
1476
+ # Projects
1477
+ Source: https://docs.getmaxy.com/projects-guide.md
1478
+
1479
+ # Projects Guide
1480
+
1481
+ ## What a Project Is
1482
+
1483
+ A project is a named body of work with multiple steps, dependencies, and a lifecycle. Unlike standalone tasks, a project has child work items that can depend on each other, a health signal that tracks progress, and lifecycle phases (planning, active, blocked, verifying, complete, abandoned).
1484
+
1485
+ Projects are ideal when the user has work involving multiple people, sequential steps, or deliverables — a kitchen refit, a client engagement, a product launch.
1486
+
1487
+ ## Creating a Project
1488
+
1489
+ Tell Maxy naturally:
1490
+
1491
+ - "Create a project for Mrs. Chen's kitchen refit — strip the old kitchen, plumbing first fix, electrical first fix, install units, then tiling and finishing"
1492
+ - "Set up a project for the bathroom renovation, standard tier, due by end of June"
1493
+ - "Start a project: boiler install for Sarah Thompson, quick job, just order parts, install, and test"
1494
+
1495
+ Maxy creates the project and all work items in one step. Dependencies between steps (e.g., "install units after plumbing and electrical") are set up automatically based on the order and relationships you describe.
1496
+
1497
+ Each project has a tier that reflects its complexity:
1498
+ - **Quick** — straightforward, few steps (e.g., boiler install)
1499
+ - **Standard** — moderate complexity, multiple phases (e.g., kitchen refit)
1500
+ - **Full** — significant scope, many dependencies (e.g., new build project)
1501
+
1502
+ ## Checking Project Status
1503
+
1504
+ Ask naturally:
1505
+
1506
+ - "What are my projects?"
1507
+ - "How's the kitchen refit going?"
1508
+ - "Show me the Davies bathroom project"
1509
+ - "What should I focus on?"
1510
+
1511
+ Maxy shows project health at a glance:
1512
+ - **Green** — on track, no issues
1513
+ - **Amber** — warning signs (overdue task or blocker)
1514
+ - **Red** — at risk (multiple overdue, critical blocker, or stale)
1515
+
1516
+ When you start a new conversation, Maxy automatically shows active project summaries so you know where things stand without asking.
1517
+
1518
+ ## Updating a Project
1519
+
1520
+ Tell Maxy when things change:
1521
+
1522
+ - "Move the kitchen refit to the active phase"
1523
+ - "The materials for the kitchen refit are delayed by a week"
1524
+ - "Update the Davies bathroom target date to July 15th"
1525
+ - "Change the boiler install to a standard tier — it's more complex than we thought"
1526
+
1527
+ Maxy records phase changes and issues as part of the project's history, creating an audit trail.
1528
+
1529
+ ## Completing a Project
1530
+
1531
+ Tell Maxy:
1532
+
1533
+ - "Mark the boiler install as done"
1534
+ - "Complete the kitchen refit project"
1535
+
1536
+ If any work items are still pending, Maxy will let you know and ask how to handle them — cancel, defer, or keep working on them.
1537
+
1538
+ ## Abandoning a Project
1539
+
1540
+ If a project is no longer needed:
1541
+
1542
+ - "Abandon the Davies bathroom — client cancelled"
1543
+ - "Stop the kitchen refit project"
1544
+
1545
+ Maxy records the reason and marks the project as abandoned.
1546
+
1547
+ ## Projects vs. Tasks
1548
+
1549
+ Use a **task** for standalone work — a single action, a reminder, a follow-up. Use a **project** when the work has multiple steps that depend on each other, a client or stakeholder, and a lifecycle that progresses through phases.
1550
+
1551
+ When you describe multi-step work, Maxy will ask if you'd like to structure it as a project. Over time, it learns your preference and stops asking.
1552
+
1553
+ ## Working a Task End to End
1554
+
1555
+ Ask Maxy naturally:
1556
+
1557
+ - "What's outstanding?"
1558
+ - "What's on my plate?"
1559
+ - "What should I work on next?"
1560
+ - "Pick something to do"
1561
+
1562
+ Maxy reads the ready set for your account, groups the open Tasks under their parent Projects, and asks you to pick one. You pick — it never auto-selects.
1563
+
1564
+ Once you pick, the loop runs end to end:
1565
+
1566
+ 1. **Grounding** — pulls the Task and its surrounding context (parent Project, related documents, prior conversation) so the run starts from what is already known, not a blank slate.
1567
+ 2. **Delegation** — routes the work to the right specialist surface. A research task goes to deep-research, an email reply to email composition, a document to professional-document, and so on. Nothing is reimplemented inline.
1568
+ 3. **Write-back** — every artefact produced (document, email, ingested file) is linked to the Task in the graph, progress is logged on the Task, and the Task is closed when done.
1569
+
1570
+ This means you can always trace a finished piece of work back to the Task that asked for it, and a Task that says "complete" always has its output attached.
1571
+
1572
+ Cross-account access is refused. A Task that belongs to a different account on the same install is invisible to this loop — Maxy will not read it, name it, or surface it.
1573
+
1574
+ ---
1575
+ # Telegram
1576
+ Source: https://docs.getmaxy.com/telegram-guide.md
1577
+
1578
+ # Telegram Guide
1579
+
1580
+ ## What the Telegram Plugin Does
1581
+
1582
+ The Telegram plugin connects Maxy to a Telegram bot. Once set up, you can:
1583
+
1584
+ - Send messages to individuals or groups via Maxy ("Send a message to the team: standup in 10 minutes")
1585
+ - Receive messages from your Telegram bot and have Maxy respond
1586
+ - Use Telegram as a channel for Maxy notifications and alerts
1587
+
1588
+ ## Setup
1589
+
1590
+ ### Step 1: Create a Telegram bot
1591
+
1592
+ 1. Open Telegram and search for `@BotFather`
1593
+ 2. Send `/newbot` and follow the prompts to choose a name and username
1594
+ 3. BotFather will give you a token — it looks like `123456789:ABCdefGhijklMNOpqrstUVWxyz`
1595
+ 4. Keep this token — you'll need it in the next step
1596
+
1597
+ ### Step 2: Connect the plugin
1598
+
1599
+ Tell Maxy: "Set up Telegram" or "Configure the Telegram bot."
1600
+
1601
+ Maxy will ask for your bot token, then save it and activate the plugin. The bot is now connected.
1602
+
1603
+ ### Step 3: Start the bot
1604
+
1605
+ In Telegram, open your bot and send `/start`. The bot is now active and listening.
1606
+
1607
+ ## Sending Messages via Maxy
1608
+
1609
+ Once connected, tell Maxy to send messages on your behalf:
1610
+
1611
+ - "Send a Telegram message to John: I'll be 10 minutes late"
1612
+ - "Message the team channel: server maintenance at 11pm"
1613
+ - "Tell Sarah via Telegram that the proposal is ready"
1614
+
1615
+ Maxy needs a chat ID or username to target a specific person or group. For groups, you'll need to add the bot to the group first.
1616
+
1617
+ ## Getting a Chat ID
1618
+
1619
+ To message a specific person or group, Maxy needs their chat ID. The easiest way:
1620
+
1621
+ 1. Have the person (or yourself) send any message to your bot
1622
+ 2. Ask Maxy: "What chat IDs have messaged the bot recently?"
1623
+ 3. Maxy will look up the message history and show you the IDs
1624
+
1625
+ ## Message History
1626
+
1627
+ Ask Maxy: "What messages has the bot received?" or "Show recent Telegram activity."
1628
+
1629
+ ## Troubleshooting
1630
+
1631
+ **Bot not responding:** Check that the bot token is correct — ask Maxy "What's my Telegram bot token configured as?" and verify it matches BotFather.
1632
+
1633
+ **Can't send to a group:** The bot must be a member of the group. Add it via the group settings in Telegram, then try again.
1634
+
1635
+ **Messages not arriving:** Make sure the bot hasn't been blocked. Try sending `/start` to the bot directly.
1636
+
1637
+ ---
1638
+ # Outlook
1639
+ Source: https://docs.getmaxy.com/outlook-guide.md
1640
+
1641
+ # Outlook Plugin — Operator Guide
1642
+
1643
+ The `outlook` plugin gives the admin agent read-only access to Microsoft 365 / Outlook.com via Microsoft Graph. Per-account OAuth (Auth Code + PKCE), encrypted token storage, automatic refresh.
1644
+
1645
+ ## Quickstart
1646
+
1647
+ 1. **Register an Entra app once per Maxy install** — see `platform/plugins/outlook/references/auth.md` for full steps. Set `OUTLOOK_CLIENT_ID` (and `OUTLOOK_TENANT_ID`, default `common`) in the operator's environment.
1648
+ 2. **Per account: register the Outlook account** — in admin chat, ask the agent to "register my Outlook account". The agent runs `outlook-account-register`, which prints an authorization URL.
1649
+ 3. **Open the URL in the VNC browser** — sign in to your Microsoft account, consent to the requested scopes (`offline_access`, `User.Read`, `Mail.Read`, `Calendars.Read`, `Contacts.Read`).
1650
+ 4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.
1651
+
1652
+ ## Tools
1653
+
1654
+ | Tool | Purpose |
1655
+ |------|---------|
1656
+ | `outlook-account-register` | Run the PKCE flow for this account. One-time per account; re-run if tokens expire (90 days) or consent is revoked. |
1657
+ | `outlook-mail-list` | Recent mail. Default top=25, folder=Inbox. |
1658
+ | `outlook-mail-search` | Microsoft Graph `$search` over the mailbox. |
1659
+ | `outlook-calendar-list` | Calendar events in next rangeDays days (default 7, max 365). |
1660
+ | `outlook-calendar-event` | Full detail of a single event by id. |
1661
+ | `outlook-contacts-list` | Top contacts. Default top=50. |
1662
+ | `outlook-mailbox-info` | Health probe — auth state, refresh-window, folder count. |
1663
+
1664
+ ## Observability
1665
+
1666
+ All log lines start with `[outlook-mcp]` and write to `server.log`. They are key=value, account-scoped:
1667
+
1668
+ | Event | Line shape |
1669
+ |-------|------------|
1670
+ | Auth init | `auth-init account=<id> codeChallenge=<sha256-prefix-8> redirectPath=<callback-path>` |
1671
+ | Auth callback | `auth-callback account=<id> elapsedMs=<N>` |
1672
+ | Auth ok | `auth-ok account=<id> graphUserId=<id> scopes=<csv> tokenExpSec=<N>` |
1673
+ | Token refreshed | `token-refreshed account=<id> oldExpSec=<N> newExpSec=<N>` |
1674
+ | Refresh failed | `token-refresh-failed account=<id> reason=<err>` (terminal) |
1675
+ | Mail list | `mail-list account=<id> folder=<id-or-Inbox> count=<N> elapsedMs=<N>` |
1676
+ | Mail search | `mail-search account=<id> query=<trunc-32> count=<N> elapsedMs=<N>` |
1677
+ | Calendar list | `calendar-list account=<id> rangeDays=<N> count=<N> elapsedMs=<N>` |
1678
+ | Calendar event | `calendar-event account=<id> eventId=<trunc-12> elapsedMs=<N>` |
1679
+ | Contacts list | `contacts-list account=<id> count=<N> elapsedMs=<N>` |
1680
+ | Mailbox info | `mailbox-info account=<id> tokenWithinRefreshWindow=<bool> folderCount=<N>` |
1681
+ | Graph error | `graph-error account=<id> status=<N> code=<graphErrorCode> retryAfterMs=<N-or-null>` |
1682
+ | On-prem rejected | `on-prem-rejected account=<id> mailServer=<host>` (terminal) |
1683
+
1684
+ ## Diagnostic paths
1685
+
1686
+ ```bash
1687
+ # All outlook lines for one account, last 50
1688
+ ssh laptop 'grep -E "^\[outlook-mcp\]" ~/.maxy/logs/server.log | grep "account=<id>" | tail -50'
1689
+
1690
+ # Token-leak audit — must always return zero
1691
+ grep -rn -iE "Bearer |access_token=" ~/.maxy/logs/server.log | head
1692
+ ```
1693
+
1694
+ Latency triage: `mail-list count=0 elapsedMs<200` consistent → permissions issue; `elapsedMs > 5000` → Graph slowness or DNS.
1695
+
1696
+ ## Failure modes
1697
+
1698
+ | Operator-visible message | Cause | Fix |
1699
+ |---|---|---|
1700
+ | `Outlook not connected for account=X; run outlook-account-register` | Tokens never saved | Run register tool |
1701
+ | `Outlook refresh token expired for account=X; run outlook-account-register` | >90 days since last refresh, or consent revoked | Run register tool |
1702
+ | `Outlook token refresh failed for account=X; re-auth required` | Network down at refresh time, or refresh token invalidated | Verify network; re-register |
1703
+ | `Outlook auth expired for account=X; run outlook-account-register` | Refresh-then-retry still got 401 | Re-register |
1704
+ | `Outlook rate-limited without Retry-After hint` | Graph 429 with no backoff guidance | Wait + retry; if persistent, file bug |
1705
+ | `Microsoft Graph does not support on-premises Exchange. Use earlier platform fixes (IMAP).` | Mailbox is on hybrid Exchange | Use the `email` plugin |
1706
+
1707
+ ## Out of scope
1708
+
1709
+ Write tools (send, draft, move, flag), OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.
1710
+
1711
+ ---
1712
+ # LinkedIn Extension
1713
+ Source: https://docs.getmaxy.com/linkedin-extension.md
1714
+
1715
+ # LinkedIn Extension — operator guide
1716
+
1717
+ Capture a LinkedIn profile or DM thread to your Maxy graph with one click. The plugin ships a small Chrome extension; the admin already knows how to receive its payloads.
1718
+
1719
+ ## Install (one time)
1720
+
1721
+ 1. Open `chrome://extensions`.
1722
+ 2. Toggle **Developer mode** on (top right).
1723
+ 3. Click **Load unpacked**.
1724
+ 4. Select `platform/plugins/linkedin-extension/extension/` on disk.
1725
+ 5. Click the puzzle icon → pin **Maxy LinkedIn Ingest** → open its options.
1726
+ 6. Paste two values:
1727
+ - **Admin host** — your tunnel URL, e.g. `https://your-tunnel.example.com`.
1728
+ - **Session key** — open your admin browser, copy the value of `session_key` from the cookie. (Same key the admin uses to authenticate every other admin request.)
1729
+ 7. Save. The pill is now armed.
1730
+
1731
+ ## Use
1732
+
1733
+ - **Profile** — open any `https://www.linkedin.com/in/<slug>/` page. An **Add to Maxy** pill appears in the top section. Click it. Within a few seconds the pill turns green: the profile is in your graph.
1734
+ - **DM thread** — open any `https://www.linkedin.com/messaging/thread/<id>/` conversation. The same pill appears. Click it; the full transcript is captured plus the participants and any explicit commitments (meetings booked, actions promised, prices discussed).
1735
+ - **Re-click** — the pill is idempotent. Re-clicking the same URL refreshes the document body and regenerates the summary; identities and entities `MERGE` rather than duplicate.
1736
+
1737
+ ## What lands in the graph
1738
+
1739
+ Every click produces **one `:KnowledgeDocument`** keyed on the page URL, holding the verbatim scraped text as its body and a child `:Section:Note` for the LLM summary. Structured entities layer on top, but only when the body **assertively states** them:
1740
+
1741
+ - A `:Person` for the profile subject (or each thread participant), `MERGE`d on canonical keys.
1742
+ - A `:Organization` for an asserted current employer, with a `WORKS_AT` edge from the person.
1743
+ - An `:Event` with `ATTENDED_BY` edges when a meeting time is explicitly proposed and confirmed.
1744
+ - A `:Task` with `RAISED_BY` / `ABOUT` edges when an action is promised without a specific time.
1745
+ - A `:Service` / `:PriceSpecification` when an offer is discussed.
1746
+
1747
+ Soft signals ("interested in chatting", "would love to compare notes") stay in the document body. They are never promoted to graph nodes.
1748
+
1749
+ The plugin will never create `:Communication`, `:ConversationArchive` rows (i.e. KnowledgeDocument nodes that carry `conversationIdentity`), or `:Message` nodes — those shapes are reserved for other flows (live chat, archive ingest).
1750
+
1751
+ ## When the pill turns amber
1752
+
1753
+ The pill shows **Sign in to Maxy** when your session key has expired. Click it to open the options page; paste a fresh `session_key` from your admin browser; save. The next click on the LinkedIn pill will succeed.
1754
+
1755
+ ## When the pill turns red
1756
+
1757
+ - **Missing: ...** — LinkedIn shipped a DOM change and the extractor cannot find a required field. Open a console tab on the LinkedIn page and check the `[linkedin-ext-scrape]` log line for the field names. Drop a ticket pointing at the affected selector; the [`SKILL.md`](../../plugins/linkedin-extension/skills/linkedin-extension/SKILL.md) lists the selector table and the steps for adding a fallback.
1758
+ - **Capture failed** — the admin reached, but the request did not complete cleanly. Check the admin logs (`journalctl -u maxy.service | grep linkedin-ingest`). The `[linkedin-ingest-route]` lines name the reason (`schema`, `dispatch-failed`).
1759
+
1760
+ ## Related plugins
1761
+
1762
+ - **linkedin-import** — bulk ingest of the LinkedIn ZIP export (history). Different surface; both ship and complement each other.
1763
+ - **memory.document-ingest** — the generic ingest pipeline this plugin's payloads route through. Future communication surfaces (email, Telegram, WhatsApp) plug in here too.
1764
+
1765
+ ---
1766
+ # Admin Chat Attachments
1767
+ Source: https://docs.getmaxy.com/attachments.md
1768
+
1769
+ # Admin Chat Attachments
1770
+
1771
+ What you can drag-and-drop into the admin chat window, what happens to each file, and the size caps.
1772
+
1773
+ ## Accepted file types
1774
+
1775
+ | Type | MIME | Notes |
1776
+ |------|------|-------|
1777
+ | Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | Rendered inline by the agent when relevant. |
1778
+ | PDF | `application/pdf` | The agent reads the text; scanned PDFs go via OCR if available. |
1779
+ | Plain text, Markdown, CSV, HTML | `text/plain`, `text/markdown`, `text/csv`, `text/html` | Read directly. |
1780
+ | Calendar | `text/calendar` | Ingested into the graph if the agent finds a reason to keep it. |
1781
+ | Voice note | `audio/*` | Transcribed before the message is routed to the agent. |
1782
+ | **Zip archive** | `application/zip`, `application/x-zip-compressed` | Unpacked by the agent after safety checks. See below. |
1783
+
1784
+ Anything else is refused at upload time with a message naming the type.
1785
+
1786
+ ## Size caps
1787
+
1788
+ - **Per file:** 50 MB. Enforced at the upload endpoint — files over this limit never reach disk.
1789
+ - **Per message:** up to 5 files.
1790
+ - **Uncompressed contents of a single zip:** 100 MB. A zip whose declared uncompressed total is over this limit is refused before any byte is extracted (decompression-bomb guard).
1791
+
1792
+ ## What happens with a zip archive
1793
+
1794
+ When you drop a `.zip` into chat, the agent:
1795
+
1796
+ 1. **Checks the archive is safe.** It refuses archives that try to write outside their own extraction folder, contain symlinks, are password-protected, or declare more than 100 MB of uncompressed content. You'll see the exact reason in chat if any check fails.
1797
+ 2. **Extracts it to a fresh folder.** Contents land under `{your-account-dir}/extracted/{id}/` — one folder per archive, never mixed.
1798
+ 3. **Lists what's in it.** The agent tells you the top-level entries, the total file count, and the uncompressed size.
1799
+ 4. **Asks before doing anything else.** For each class of file (text/markdown, images, PDFs, other), it proposes one next step — for example "ingest these notes into memory" or "re-attach the images back to chat so you can see them" — and waits for you to say yes.
1800
+
1801
+ Nothing is ingested, sent, or acted on automatically. The extraction is local and visible; you decide what happens next.
1802
+
1803
+ ## What is **not** supported
1804
+
1805
+ - `tar`, `tar.gz`, `7z`, `rar` — zip only. If you have one of these, unzip/convert locally and upload the zip (or the extracted files directly).
1806
+ - Nested archives — a zip-inside-a-zip is extracted one level; you can ask the agent to unpack the inner one afterwards.
1807
+ - Password-protected zips — the agent will tell you to unlock locally and re-upload.
1808
+ - Uploads larger than 50 MB — split the archive, or upload the individual files.
1809
+
1810
+ ## Where the files live
1811
+
1812
+ Uploads go to `{install-dir}/data/uploads/{account-id}/{file-id}/` — outside the platform wipe zone, so they survive re-installs. Extracted zip contents go to `{account-dir}/extracted/{file-id}/`. Both are local to your device.
1813
+
1814
+ ---
1815
+ # Answer Engine Optimisation
1816
+ Source: https://docs.getmaxy.com/aeo.md
1817
+
1818
+ # AEO (Answer Engine Optimisation) — user guide
1819
+
1820
+ The AEO plugin shapes the site for answer engines (Claude, ChatGPT, Perplexity, Google AI Overviews, Bing Copilot). It does three things: emits schema.org JSON-LD from typed graph entities, generates `/llms.txt` and `/llms-full.txt`, and audits any page against eight heuristics.
1821
+
1822
+ ## When to use it
1823
+
1824
+ - A new page is being authored and you want it to be cited when customers ask an answer engine about your service.
1825
+ - An existing page is not being cited. Audit it to find the structural reasons.
1826
+ - You're standing up a new site and want the `llms.txt` pair generated so answer engines can index your content directly.
1827
+
1828
+ ## The tools
1829
+
1830
+ ### `aeo-emit-jsonld`
1831
+
1832
+ Generates a `<script type="application/ld+json">` block for a typed entity. Two modes:
1833
+
1834
+ - **From the graph.** Pass `entityId` (Neo4j elementId of an `Organization`, `Person`, `Service`, `Product`, `CreativeWork`, `FAQPage`, `RealEstateListing`, or `Event`). The tool resolves the entity, maps the label to its schema.org type, and emits the block.
1835
+ - **Inline.** Pass `label` (one of the supported page types) plus a `properties` object. Use this when the page isn't backed by a stored entity (yet) but you want the JSON-LD shape.
1836
+
1837
+ Returns the parsed JSON-LD object and a ready-to-inline script block string. Inline the script in your page `<head>`.
1838
+
1839
+ ### `aeo-write-llms-txt`
1840
+
1841
+ Generates the `llms.txt` / `llms-full.txt` pair for the account. Source is every `KnowledgeDocument` for the account that has a `url` property. Returns both files as strings plus a count of pages skipped because they had no URL.
1842
+
1843
+ Wire the output to your site host. Convention: `/llms.txt` (index) and `/llms-full.txt` (concatenated content), served as `text/plain`. Format follows the current draft at `https://llmstxt.org/`.
1844
+
1845
+ ### `aeo-audit-page`
1846
+
1847
+ Runs the eight-heuristic audit. Pass either `url` (the tool fetches) or `html` (you supply the rendered content). Returns:
1848
+
1849
+ ```
1850
+ {
1851
+ "score": 0–100,
1852
+ "heuristics": [
1853
+ {
1854
+ "name": "structured-answer",
1855
+ "status": "pass" | "warn" | "fail",
1856
+ "detail": "first <p> after <h1> is 142 chars",
1857
+ "suggestion": "Add one ≤280-character <p> immediately after the <h1>…"
1858
+ },
1859
+
1860
+ ],
1861
+ "target": "<url or '(inline html)'>",
1862
+ "audit": { "runAt": "<iso>", "elementId": "<set when persisted>" }
1863
+ }
1864
+ ```
1865
+
1866
+ Pass `persist: true` plus `targetKnowledgeDocumentId` to write the result as an `:AEOAudit` node linked to the document.
1867
+
1868
+ ## The eight heuristics
1869
+
1870
+ | Heuristic | What it checks |
1871
+ |---|---|
1872
+ | `h1-present` | exactly one `<h1>` |
1873
+ | `jsonld-present` | at least one parseable JSON-LD block |
1874
+ | `structured-answer` | first `<p>` after `<h1>` is ≤280 chars |
1875
+ | `faq-section` | `FAQPage` JSON-LD on the page |
1876
+ | `meta-description` | 80–160 char `<meta name="description">` |
1877
+ | `canonical-url` | `<link rel="canonical">` present |
1878
+ | `og-tags` | `og:title` and `og:description` both set |
1879
+ | `heading-hierarchy` | no level skips (h1→h3 etc.) |
1880
+
1881
+ `structured-answer` is the highest-impact: that one paragraph is what gets lifted into the engine's answer. A page can fail every other heuristic and still be cited if this one passes.
1882
+
1883
+ ## Observability
1884
+
1885
+ Every tool emits a single log line per invocation:
1886
+
1887
+ - `[aeo-emit-jsonld] entityId=… schemaType=… source=graph|inline`
1888
+ - `[aeo-llms-txt] site=… pages=… skippedNoUrl=… indexBytes=… fullBytes=…`
1889
+ - `[aeo-audit] target=… score=… fails=… warns=…`
1890
+
1891
+ Diagnostic path: `grep -E '^\[aeo-' platform-logs/*.log | grep <urlOrEntityId>`.
1892
+
1893
+ ## What this plugin does not do
1894
+
1895
+ - **No citation monitor — out of scope.** Tracking whether your brand is cited by Claude / ChatGPT / Perplexity / Gemini would require multi-engine answer harvesting that doesn't fit maxy-code's no-API-key architecture. Archived without sprinting (Task 363). Check citation manually when needed.
1896
+ - **No auto-emission on page render.** `aeo-emit-jsonld` is callable on demand. Wiring it into the platform's page-generator render path is per-renderer work, filed as a follow-up.
1897
+ - **No publish-hook regeneration of `llms.txt`.** The tool runs on demand. Hooking it into the publish event is a follow-up.
1898
+
1899
+ ## See also
1900
+
1901
+ - Plugin manifest: `platform/plugins/aeo/PLUGIN.md`
1902
+ - Structured-answer template: `platform/plugins/aeo/skills/structured-answer/SKILL.md`
1903
+ - Schema declaration: `platform/neo4j/schema.cypher` (search `AEOAudit`)
1904
+ - Spec source: `https://llmstxt.org/`
1905
+
1906
+ ---
1907
+ # Session Retrospective
1908
+ Source: https://docs.getmaxy.com/session-retrospective.md
1909
+
1910
+ # Session-end retrospective
1911
+
1912
+ When you end an admin session by typing `/end`, `/archive`, `end session`, or `archive this session`, the admin agent runs one extra turn before the session closes. It walks the session and writes down four kinds of thing that would otherwise be lost:
1913
+
1914
+ - corrections and learnings you gave the agent during the session,
1915
+ - tonal and working-style preferences worth carrying forward,
1916
+ - people, decisions, commitments, or business facts that came up but were not yet saved to the graph,
1917
+ - typed edges between any new prose-bearing nodes (messages, meetings, notes, pages) and the entities they mention — the auto-extraction pass that "wires the graph" so future questions can hop from a person to the companies they founded to the events they attended.
1918
+
1919
+ The next session starts against an up-to-date picture of you and your business — not just the parts that got recorded mid-flow.
1920
+
1921
+ The extra turn is one or two messages from the agent, then a short summary of what was written. It runs inside the same session you were already in; nothing happens in the background, no second session is spawned. The typed-edge pass itself is delegated to the `database-operator` specialist so the writes land where graph writes are supposed to live.
1922
+
1923
+ ## When the retrospective is skipped
1924
+
1925
+ - Clicking the Archive button in the Sidebar closes the session directly and skips the retrospective. The four typed phrases above are the only signals that trigger it.
1926
+ - Closing the browser tab or losing power closes the session without the retrospective. The mid-session recording route (the agent writes facts as they come up) is the primary safety net; the retrospective is the catch-net for what slips through.
1927
+
1928
+ Skipped sessions do not lose typed-edge work. The next session's retrospective picks up any prose nodes the previous session wrote, because the pass is scoped by "what changed since the last completed retrospective" — not by which session wrote it. The cost of skipping is one session of delay, not lost extraction.
1929
+
1930
+ ## Skip-rate visibility
1931
+
1932
+ At the start of every admin session, the agent checks how many of your recent sessions closed without firing the retrospective. If any did, it surfaces one line — for example, "the last 10 sessions: 4 ended without a retrospective; their typed edges land at the next `/end`." This is informational, not nagging — it lets you choose to end the next session properly so the deferred extraction lands sooner.
1933
+
1934
+ ## What you see if the agent tries to skip it
1935
+
1936
+ If the agent replies with a prose summary of "what it learned" without actually performing the retrospective, the session does not close — the Stop gate blocks until the agent calls the deterministic completion tool. You can tell it happened correctly by the final reply naming the five counts (learnings, tonal observations, graph updates, typed edges accepted, prose nodes scanned); the absence of those counts is the signal that something went wrong.
1937
+
1938
+ ---
1939
+ # Visitor Graph
1940
+ Source: https://docs.getmaxy.com/visitor-graph.md
1941
+
1942
+ # Visitor graph (Task 357)
1943
+
1944
+ Behavioural analytics that connect anonymous page visits to known `:Person` contacts. Replaces the anonymous click-through metric from Task 336 with a fully attributed graph.
1945
+
1946
+ ## What this gives the operator
1947
+
1948
+ - A morning briefing surface: "who has been on the site overnight, and what did they look at?"
1949
+ - An engagement-ranked nurture queue, ordered by recency × depth × dwell.
1950
+ - A graph-backed click-through-rate report for property recommendations.
1951
+ - A full event timeline for any one session, for prep and diagnosis.
1952
+ - A signed-cookie that recognises a named visitor on later visits without re-clicking the marketing link.
1953
+
1954
+ ## Data model
1955
+
1956
+ | Node | Meaning |
1957
+ |------|---------|
1958
+ | `:Session` | One browser tab session. Composite key `(accountId, sessionId)`. |
1959
+ | `:AnonVisitor` | Pre-identification browser identity. Merges into `:Person` on first signed-token click. |
1960
+ | `:PageView` | One page load. Carries `referrer`, `path`, optional `dwellMs`. |
1961
+ | `:Click` | One DOM click on a tagged element (`data-track="<label>"`). |
1962
+ | `:ScrollMilestone` | Roll-up of scroll depth — one node per `:PageView`, `maxDepth` ∈ {25,50,75,100}. |
1963
+ | `:Page` | URL metadata. Content-only, survives erasure. |
1964
+ | `:Recommendation` | Materialised `[property-recommended]` log line for CTR computation. |
1965
+
1966
+ | Edge | Direction |
1967
+ |------|-----------|
1968
+ | `VISITED` | `Person → Session` or `AnonVisitor → Session` |
1969
+ | `OWNS_VISITOR` | `Person → AnonVisitor` (cross-session merge) |
1970
+ | `HAS_EVENT` | `Session → PageView / Click / ScrollMilestone` |
1971
+ | `OF_PAGE` | `PageView → Page` |
1972
+ | `OF_LISTING` | `PageView → Listing` |
1973
+ | `FOR_SESSION` | `Recommendation → Session` |
1974
+
1975
+ ## How identity gets resolved
1976
+
1977
+ The signed-token cookie `mxy_v` carries a `:Person` elementId, signed HMAC-SHA256 with a brand-local 32-byte secret (file at `~/.<brand>/credentials/visitor-token-secret`, minted on first read). When the recommender's `/listings/<slug>/click?session=<sk>&v=<token>` URL is visited:
1978
+
1979
+ 1. The click handler verifies the HMAC on `<token>`.
1980
+ 2. If valid, the same token value is written into `mxy_v` (Max-Age 30 days, SameSite=Lax, HttpOnly, Secure).
1981
+ 3. On subsequent visits, `POST /v/event` reads the cookie, verifies it, and attributes every event to the bound `:Person`.
1982
+
1983
+ When a previously-anonymous browser binds for the first time, any `:Session` already attributed to the `:AnonVisitor` is re-attached to the `:Person`, and the merge fires `[anonvisitor-merge]` with the count of reattributed sessions.
1984
+
1985
+ ## Tools
1986
+
1987
+ All under the `real-agent-buyers` plugin, admin-side only:
1988
+
1989
+ | Tool | Purpose |
1990
+ |------|---------|
1991
+ | `visitor-recent-by-person` | Recent sessions for a known `:Person` (morning round, 1:1 prep). |
1992
+ | `visitor-recent-by-page` | Recent visitors of a given listing slug or URL. |
1993
+ | `visitor-engagement-score` | Engagement-ranked `:Person` list (nurture queue). |
1994
+ | `visitor-recommendation-ctr` | Graph-backed CTR over a window, joined from `:Recommendation` and `:Click` nodes. |
1995
+ | `visitor-session-detail` | Full event timeline for one `:Session`. |
1996
+ | `visitor-event-ingest` | Admin companion to `POST /v/event` for test harness work. |
1997
+ | `visitor-backfill-from-logs` | One-shot importer: parses `[property-recommended]` and `[property-card-click]` log lines into the graph; used to recover late-arriving sessions and for ad-hoc forensics. |
1998
+ | `mint-visitor-token` | Mints a signed token bound to a `:Person` for outbound URLs in `morning-round`, `lead-nurturing`, `vendor-updates`. Returns `{ token, expiryMs }`; the agent appends `&v=<token>` to `/listings/<slug>/click?session=<sk>`. Same secret file as the UI server; both processes share it via the wx-create pattern. (Task 362) |
1999
+
2000
+ ## Privacy
2001
+
2002
+ The full description is at `/privacy` on every brand domain. Highlights:
2003
+
2004
+ - First-party cookie only, no third-party scripts.
2005
+ - Retention is erasure-on-request, not time-based; visit data persists until a `:Person` is erased.
2006
+ - Right-to-erasure cascades through `contact-erase`: `:Session`, every `:HAS_EVENT` child, and any owned `:AnonVisitor` are removed when the `:Person` is erased. `:Page` and `:Listing` are content metadata and intentionally preserved.
2007
+
2008
+ ## Verification
2009
+
2010
+ Quick checks the operator can run after deployment:
2011
+
2012
+ 1. Load a published listing page; grep `[visitor-event] type=pageview` in `server.log` within 1s.
2013
+ 2. Scroll past 50%; grep `[visitor-event] type=scroll depth=50`.
2014
+ 3. Click an element marked `data-track="floorplan"`; grep `[visitor-event] type=click label=floorplan`.
2015
+ 4. Run `visitor-backfill-from-logs` over a log window where live writes were lost (process restart, etc.); the response reports `recWritten` and `clickWritten` counts. Subsequent runs over the same window are idempotent for `:Recommendation` and append-only for `:Click`.
2016
+
2017
+ ## Failure signals
2018
+
2019
+ | Symptom | What it means | Where to look |
2020
+ |---------|---------------|---------------|
2021
+ | `[visitor-event]` count drops to zero with no `[v-event-error]` | Pixel silently failing on the brand domain (probably CSP, CORS, or origin mismatch). | Check brand.json `publishedSiteOrigins`; check browser console on a published listing page. |
2022
+ | `[token-bind] reject reason=bad-sig` spikes | HMAC verify failing — either the secret rotated and old cookies are being rejected (expected during rotation) or the recommender is minting against a stale secret. | Compare `~/.<brand>/credentials/visitor-token-secret` across processes. |
2023
+ | `[anonvisitor-merge]` never fires after first signed-token click | The pixel isn't reading the cookie. | Inspect the `mxy_v` cookie in DevTools; check CORS `Access-Control-Allow-Credentials: true`. |
2024
+ | `[v-event-error] reason=rate-limit` for legitimate operator traffic | Operator IP shares a NAT with high-volume crawlers. | Adjust `RATE_LIMIT` in `visitor-event.ts` or whitelist the IP at the proxy. |
2025
+
2026
+ ---
2027
+ # Voice Mirror
2028
+ Source: https://docs.getmaxy.com/voice-mirror-guide.md
2029
+
2030
+ # Voice Mirror Guide
2031
+
2032
+ ## What It Does
2033
+
2034
+ Maxy reads emails, posts, documents, and your own chat messages, and uses them to make sure agent-drafted copy reads like you wrote it — not like generic AI prose.
2035
+
2036
+ Anthropic models cannot be fine-tuned. Voice mirror works by feeding the model two things alongside every drafting task:
2037
+
2038
+ - A **style card** distilled from your own writing — sentence length, register, favourite phrases, things you never say.
2039
+ - A handful of **exemplars** — actual paragraphs you wrote, picked for relevance to the current draft.
2040
+
2041
+ The model conditions on both and produces copy that reads as yours.
2042
+
2043
+ Voice mirror maintains a separate profile for each type of content you write: plain text, email, social posts, articles, notes, and marketing copy. The right profile is applied automatically based on what you're drafting.
2044
+
2045
+ When Maxy produces anything that will go out under your name (a document, public-facing copy, anything you will send onward), it applies your voice before it writes the first line, not after you ask for a rewrite. You do not have to request it.
2046
+
2047
+ ## How Your Voice Is Captured
2048
+
2049
+ ### Automatically — from chat
2050
+
2051
+ Every admin chat session feeds your writing into the `text` profile automatically. When the session ends, Maxy reads the transcript, filters out slash commands, system messages, and large paste blocks, and adds each genuine turn as a corpus entry. This happens in the background with no action needed from you.
2052
+
2053
+ ### Via backfill — from historical content
2054
+
2055
+ Use the backfill flow to teach Maxy your writing from emails, documents, and social posts you've already written.
2056
+
2057
+ In the admin chat, ask: **"Start the voice-mirror backfill."**
2058
+
2059
+ Maxy asks which stream to backfill first — discrete documents (emails, posts, PDFs) or chat archives (WhatsApp, Telegram, etc.). **Pick chat archives first if you have any imported.** Chat is where your unguarded voice lives; email is the same voice in dress clothes.
2060
+
2061
+ - **Chat archives** — tag a whole conversation in one click. Maxy doesn't ask about individual messages because chunks within a conversation almost always share the same voice. Options per conversation: yours, mixed (multiple authors on your side — rare), or not yours (e.g. a Slack channel where you only forwarded other people's messages).
2062
+ - **Documents and posts** — paginated 10 at a time. Maxy shows each item with its detected format (email, article, note, social-post). Tag the whole batch, a subset by number, or per-item if you want a precise label like `human-led-agent-assisted` (you wrote the content, Maxy polished). Override the detected format if one is wrong.
2063
+
2064
+ Skip a batch if none qualify, or stop at any time — the next session resumes where you left off.
2065
+
2066
+ ## Distilling Your Profile
2067
+
2068
+ Once you have corpus entries tagged (or after automatic PTY ingestion fills the `text` profile), ask: **"Build my voice profile."**
2069
+
2070
+ Maxy reads the corpus for each format, summarises your style as a YAML card, and saves it to the graph. It picks up your sentence rhythms, the constructions you reach for, the words you avoid — separately for email, articles, social posts, and so on.
2071
+
2072
+ The profile re-runs automatically when your corpus grows by ≥20% for any format or every 30 days, whichever comes first.
2073
+
2074
+ ## Feedback Loop
2075
+
2076
+ When you edit an agent draft before sending — shorten a sentence, change a sign-off, swap a phrase — Maxy captures the edit and feeds it into the next distillation. The more you edit, the closer the voice gets.
2077
+
2078
+ This happens silently as part of the edit-loop in any drafting skill (email composition is the first wired surface).
2079
+
2080
+ ## Opt-Out Per Skill
2081
+
2082
+ Voice mirror is on by default for every drafting skill. To opt out for one, set `voiceMirror: false` in that skill's frontmatter. The skill falls back to a neutral British business register.
2083
+
2084
+ ## What It Won't Do
2085
+
2086
+ - **Blend voices** — one profile set per person, no "Joel + Neo combined".
2087
+ - **Copy public figures** — voice mirror only learns from your own writing.
2088
+ - **Clone audio** — text only, no speech synthesis.
2089
+ - **Guess** — historical content stays `unknown` until you mark it. Maxy never auto-classifies your writing. (Automatic ingestion applies only to your live PTY sessions where authorship is certain.)
2090
+
2091
+ ## Status
2092
+
2093
+ Voice mirror is live end-to-end. Six corpus formats (text, email, social-post, article, note, marketing-copy), five tools (`voice-tag-content`, `voice-distil-profile`, `voice-retrieve-conditioning`, `voice-record-feedback`, `voice-ingest-session-text`), automatic PTY-turn ingestion via SessionEnd hook, and wiring into the three live drafting surfaces (email composition, property brochures, investor data room) are all live. When no voice profile exists on the account for a given format, every drafting skill degrades gracefully — the output matches what it was before voice mirror was installed.
2094
+
2095
+ ---
2096
+ # Admin UI Reference
2097
+ Source: https://docs.getmaxy.com/admin-ui.md
2098
+
2099
+ # Admin UI reference
2100
+
2101
+ A compact map of the admin web app: every `/api/admin/*` mount, every
2102
+ sidebar surface, and the operator-facing widgets that read host or session
2103
+ state. The deep architecture lives in [`platform.md`](platform.md) (UI
2104
+ layout, session reconcile, route lifecycle) and
2105
+ [`admin-session.md`](admin-session.md) (the session-cookie / PIN-rebind /
2106
+ SDK-resume contract). This file is the index that points at them.
2107
+
2108
+ ## Scope and tree decision
2109
+
2110
+ The `maxy-code/` tree does **not** ship a `.docs/platform.md` developer
2111
+ doc. The legacy root tree (`getmaxy/`) carries one at
2112
+ `.docs/platform.md` for the original Maxy installer; the Maxy Code tree
2113
+ keeps its architecture surface in two places:
2114
+
2115
+ - `maxy-code-prd.md` at the repo root — product requirements and the
2116
+ source of truth for every task in `.tasks/`.
2117
+ - `platform/plugins/docs/references/platform.md` — operator-facing
2118
+ architecture, loaded by the docs plugin at session start.
2119
+
2120
+ Anything that would have gone into `maxy-code/.docs/platform.md` belongs
2121
+ in one of those two files instead. `maxy-code/.docs/` itself is
2122
+ reserved for vertical / integration notes (LinkedIn extension,
2123
+ PropertyData, Real Agent standalone, MCP server inventory) — not for
2124
+ core-platform docs.
2125
+
2126
+ ## Admin Hono routes
2127
+
2128
+ Every admin sub-app is mounted by
2129
+ [`platform/ui/server/routes/admin/index.ts`](../../../ui/server/routes/admin/index.ts)
2130
+ under a per-area prefix. The outer `requireAdminSession` middleware
2131
+ runs in `server/index.ts` before the aggregator; individual handlers
2132
+ re-apply `requireAdminSession` where they need a resolved `senderId`.
2133
+
2134
+ `/actions` and `/version` are **not** mounted here — they live on
2135
+ `maxy-edge.service` via `server/edge-admin.ts` so the upgrade view
2136
+ survives the mid-run restart of the brand service. Double-mounting
2137
+ either is a regression.
2138
+
2139
+ ### Sessions and chat
2140
+
2141
+ | Mount | Purpose | Key methods |
2142
+ |---|---|---|
2143
+ | `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
2144
+ | `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
2145
+ | `/sidebar-sessions` | Sole data path for the sidebar Sessions list (Tasks 538 + 543). One JSONL on disk equals one row. The row's delete button (Task 543) is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone (Task 543) — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. | `GET /` |
2146
+ | `/session-delete` | POST `{ sessionId, projectDir }` (Task 543). Best-effort SIGTERM of the live PID (resolved from `sessions/<pid>.json` body match) then unlink the JSONL + `<sid>.meta.json` sidecar. Absent PID file is not an error. Containment: `projectDir` must live under `<CLAUDE_CONFIG_DIR>/projects/`. | `POST /` |
2147
+ | `/session-rc-spawn` | POST `{ sessionId?, name? }` (Task 543). Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
2148
+ | `/claude-sessions` | **Spawn surface only** (Task 500). The single `POST /` is shared by three callers: the public/visitor bridge, `linkedin-ingest`, and the turn-completed-graph-write Stop-hook recorder. The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
2149
+
2150
+ Task 500 — **admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot. The maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.
2151
+
2152
+ ### Graph
2153
+
2154
+ | Mount | Purpose |
2155
+ |---|---|
2156
+ | `/graph-search` | Filtered node search backing the `/graph` page filter chips. |
2157
+ | `/graph-subgraph` | Neighbourhood expansion around a focal node. |
2158
+ | `/graph-delete` | Soft-trash a node (sets `_trashed:true`). |
2159
+ | `/graph-restore` | Undo trash. |
2160
+ | `/graph-labels-in-graph` | Distinct label list for the filter dropdown. |
2161
+ | `/graph-default-view` | Account-scoped saved view (zoom, focal id, filters). |
2162
+
2163
+ ### Artefacts and files
2164
+
2165
+ | Mount | Purpose |
2166
+ |---|---|
2167
+ | `/sidebar-artefacts` | Lists every editable artefact for the sidebar Artefacts view (KnowledgeDocuments + this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates). |
2168
+ | `/sidebar-artefact-content` | Reads a single artefact's bytes for the artefact pane. |
2169
+ | `/sidebar-artefact-save` | Persists an artefact edit. |
2170
+ | `/attachment` | Per-attachment binary fetch (images, PDFs, etc.). |
2171
+ | `/files` | File browser CRUD (list, download, upload, delete). |
2172
+
2173
+ **Artefact download resolution (Task 524).** Clicking a sidebar Artefacts row
2174
+ streams the `KnowledgeDocument`'s real backing file, which can live in one of
2175
+ three on-disk classes: the admin-UI upload store (`<DATA_ROOT>/uploads/<acc>/
2176
+ <attachmentId>/`), the agent-authored output dir (`<DATA_ROOT>/accounts/<acc>/
2177
+ output/<name>`), and the Claude-agent upload store
2178
+ (`<CLAUDE_CONFIG_DIR>/uploads/<attachmentId>/`, which is **outside** DATA_ROOT).
2179
+ `memory-ingest` persists the real path on the node as `KnowledgeDocument.sourcePath`
2180
+ (skipped for web docs, whose temp file is unlinked at ingest), so new ingests
2181
+ resolve deterministically; pre-existing rows fall back across the three classes.
2182
+ The download route (`/files/download`) accepts `root=data` (default) or
2183
+ `root=claude-uploads`; the config-dir root carries no accountId path segment, so
2184
+ it is account-scoped by a graph-ownership check (the attachmentId must map to a
2185
+ `KnowledgeDocument` carrying the caller's accountId) rather than by path
2186
+ partition. Resolution emits `[admin/sidebar-artefacts] download-resolved via=…`;
2187
+ a web/transient doc with no persisted file is `not-downloadable reason=no-persisted-file`.
2188
+
2189
+ ### Browser / device-browser
2190
+
2191
+ | Mount | Purpose |
2192
+ |---|---|
2193
+ | `/browser` | Programmatic Chromium launcher used by `personal-assistant` browser-automation flows. |
2194
+ | `/browser-iframe` | Browser-iframe event ingest from the in-app preview surface. |
2195
+ | `/device-browser` | Drives the device's own browser tab (VNC Chromium on Pi). |
2196
+
2197
+ ### Diagnostics
2198
+
2199
+ | Mount | Purpose |
2200
+ |---|---|
2201
+ | `/logs` | Server log tail with `type=stream\|error\|sse` and `sessionId` filters; powers the in-chat **View logs** popover (see [`internals.md`](internals.md) "Conversations modal — View logs"). |
2202
+ | `/events` | Generic SSE event ingest from the UI client. |
2203
+ | `/log-ingest` | Loopback-only structured log ingest for MCP and PTY-spawn observability lines (see [`admin-session.md`](admin-session.md) "Memory MCP write-path outcome lines"). |
2204
+ | `/claude-info` | Returns Claude CLI binary path, version, and OAuth status. |
2205
+ | `/claude-capabilities` | Returns the resolved capability matrix the UI uses to gate features. |
2206
+ | `/agents` | Lists every installed agent template; supports `DELETE /:slug` for user-created public agents and `POST /:slug/project` for project assignment. |
2207
+ | `/cloudflare` | Tunnel setup surface — see [`cloudflare.md`](cloudflare.md) for the OAuth flow. |
2208
+ | `/linkedin-ingest` | LinkedIn Basic Data Export ingest entry point (see [`linkedin-extension.md`](linkedin-extension.md)). |
2209
+ | `/health-brand` | Brand-process liveness + 1 s Neo4j probe. Returns `{ok, processStartedAt, version, conversationDb: 'ok'\|'error', uptimeMs}`. The `processStartedAt` is captured at module load, so a stale value after an armed restart means the brand process never came back. |
2210
+ | `/system-stats` | Host CPU / RAM / load probe. See next section. |
2211
+
2212
+ The companion `/api/admin/version` and `/api/admin/actions` routes are
2213
+ served by `maxy-edge.service`, not this aggregator. The edge process
2214
+ keeps the upgrade view alive while the brand service restarts.
2215
+
2216
+ ## System-stats widget
2217
+
2218
+ Source:
2219
+ [`server/routes/admin/system-stats.ts`](../../../ui/server/routes/admin/system-stats.ts)
2220
+ (route) and `SystemStatsWidget` in
2221
+ [`platform/ui/app/Sidebar.tsx`](../../../ui/app/Sidebar.tsx)
2222
+ (consumer). CSS lives under `.system-stats*` in
2223
+ [`platform/ui/app/globals.css`](../../../ui/app/globals.css).
2224
+
2225
+ **What the widget shows.** A compact block at the foot of the sidebar
2226
+ with one row per metric: `CPU 73%` and `RAM 71%`, each followed by its
2227
+ own 4 px saturation bar. Bar fill colour is a smooth green → amber →
2228
+ red gradient computed as `hsl(140·(1−pct), 65%, 45%)` so the colour
2229
+ reflects each metric's own load. Hidden when the sidebar is collapsed
2230
+ to its 56 px icon rail.
2231
+
2232
+ **Where the numbers come from.** `GET /api/admin/system-stats` returns a
2233
+ snapshot for the host. On Linux the route reads `/proc/stat` twice 100 ms
2234
+ apart and computes `cpuPct = 1 - idleDelta / totalDelta`; `memUsedPct`
2235
+ comes from `(MemTotal - MemAvailable) / MemTotal` in `/proc/meminfo`; the
2236
+ three load averages come from `/proc/loadavg`. A single-flight module
2237
+ cache means concurrent callers share one in-flight pair of `/proc/stat`
2238
+ reads. On darwin and other non-Linux platforms the route falls back to
2239
+ `os.totalmem() / os.freemem() / os.loadavg()` and returns `cpuPct: null`
2240
+ — the widget renders a dash rather than fake a value.
2241
+
2242
+ **Refresh cadence.** The widget polls every 5 s
2243
+ (`SYSTEM_STATS_POLL_MS`). Polling pauses while `document.hidden` is true
2244
+ (tab in background) and resumes on `visibilitychange`. On unmount the
2245
+ interval is torn down and the visibility listener removed.
2246
+
2247
+ **Thresholds.**
2248
+
2249
+ | Class | Trigger | Visual |
2250
+ |---|---|---|
2251
+ | `.system-stats--warn` | `cpuPct >= 0.9` **or** `memUsedPct >= 0.9` | Widget text turns `--danger` red. Bar fill colour is independent (set by the per-bar hue gradient), so the figure text is what carries the warn signal at this band. |
2252
+ | `.system-stats--crit` | `cpuPct >= 0.98` **or** `memUsedPct >= 0.98` | Adds a 1.2 s pulsing background animation (`@keyframes system-stats-crit-pulse`) on top of the warn colour. |
2253
+ | `.system-stats__fig--warn` | Per-figure: applied to the specific CPU or RAM span that breached `0.9`. | Same red colour, lets the operator see which of the two figures crossed first. |
2254
+
2255
+ The warn threshold matches the threshold the operator most commonly
2256
+ cares about (a fully-loaded 4-core Pi at 16 GiB RAM crosses 0.9 long
2257
+ before anything else on the host notices). The crit threshold pulses
2258
+ because it is the band where a swap-thrash episode becomes likely.
2259
+
2260
+ **Failure handling.** Any read or parse error inside the route returns
2261
+ HTTP 200 with `{degraded: true, reason, sampledAtMs}` so the widget
2262
+ keeps showing its last-known value instead of flashing zeros. Failed
2263
+ fetches log `[admin-ui] system-stats-fetch-failed status=<code>` (or
2264
+ `reason=<message>`) to the browser console; successful polls are silent
2265
+ client-side. Server-side every poll logs one
2266
+ `[system-stats] poll cpuPct=<f3> memUsedPct=<f3> loadAvg1=<f2> swapUsedPct=<f3> platform=<linux|darwin|other> ms=<n>`
2267
+ line; errors emit
2268
+ `[system-stats] error file=<path|parse> reason=<message>`.
2269
+
2270
+ **Diagnostic grep.**
2271
+
2272
+ ```bash
2273
+ grep '\[system-stats\] poll' ~/.${brand}/logs/server.log | tail -20
2274
+ grep '\[system-stats\] error' ~/.${brand}/logs/server.log | tail
2275
+ ```
2276
+
2277
+ A 0.000 reading that persists across many polls while `top` shows load
2278
+ is the delta-cache regression signature (the single-flight promise was
2279
+ not released).
2280
+
2281
+ ## Sidebar surfaces
2282
+
2283
+ The sidebar is the entire left column of the admin UI. Its full layout,
2284
+ responsive breakpoints, drag-resize behaviour, and the
2285
+ new-session strip / nav rows / sessions list / footer ordering are
2286
+ documented in
2287
+ [`platform.md`](platform.md) "The Web Interface" — that paragraph is
2288
+ authoritative. This section names the surfaces and what backs each.
2289
+
2290
+ | Surface | What it does | Backed by |
2291
+ |---|---|---|
2292
+ | `+ New session` button | Opens `NewSessionModal`, which POSTs `{channel, permissionMode, model, initialMessage}` to `/api/admin/claude-sessions`. | `claude-sessions.ts` |
2293
+ | Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
2294
+ | Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
2295
+ | Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. | `/claude-sessions/events`, `/claude-sessions` |
2296
+ | Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
2297
+ | Artefacts list | Lists every KnowledgeDocument plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
2298
+ | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
2299
+ | Footer | Operator avatar, name, role, and the actions popover. | `session.ts` |
2300
+
2301
+ ## Artefact pane
2302
+
2303
+ The right-hand pane that opens when the operator selects an artefact,
2304
+ clicks a project (Graph view), or opens Browser / Data / Graph from the
2305
+ menu. It holds the surface side-by-side with the conversation so the
2306
+ chat stays live.
2307
+
2308
+ - Editable artefacts (KnowledgeDocuments + the account's own IDENTITY /
2309
+ SOUL / KNOWLEDGE) auto-save on type via
2310
+ `POST /api/admin/sidebar-artefact-save`.
2311
+ - Read-only artefacts (specialist agent templates) cannot be edited
2312
+ because they ship with Maxy and would be overwritten on the next
2313
+ install.
2314
+ - PDF artefacts render inline; non-PDF binaries fall back to a Download
2315
+ button when the browser has no native viewer.
2316
+ - Orphan rows (no readable file on disk, unsupported content type) show
2317
+ a one-line banner explaining the skip instead of opening to a blank
2318
+ pane.
2319
+
2320
+ Below 1080 px the pane hides and Browser / Data / Graph open as
2321
+ full-window pages instead. The chat-and-pane divider is drag-resizable
2322
+ on every admin page; double-click resets to half the available width
2323
+ (viewport minus sidebar), clamped to the per-pane min-width floors. The
2324
+ chosen width is remembered across reloads.
2325
+
2326
+ ## Health vs version
2327
+
2328
+ Two endpoints, two surfaces, two restart-survival roles:
2329
+
2330
+ - `GET /api/admin/health-brand` (this aggregator) — brand-process
2331
+ liveness. `processStartedAt` resets on every brand-service restart;
2332
+ Neo4j probe is bounded to 1 s and reports
2333
+ `conversationDb: 'ok' | 'error'`. Use this to confirm the brand
2334
+ process came back after a Cloudflare-setup armed restart.
2335
+ - `GET /api/admin/version` (maxy-edge) — installer / brand version
2336
+ string. Hosted on `maxy-edge.service` so the Software Update modal
2337
+ can read it while the brand service is mid-restart.
2338
+
2339
+ ## Related references
2340
+
2341
+ - [`platform.md`](platform.md) — UI layout, session reconcile model,
2342
+ artefact pane behaviour in full detail, breakpoints.
2343
+ - [`admin-session.md`](admin-session.md) — admin session token, PIN-
2344
+ rebind, SDK-resume, turn-recorder lifecycle, structured log lines.
2345
+ - [`internals.md`](internals.md) — retrieval pipeline, recorder
2346
+ auto-archive, graph-prune-denylist surface, conversation logs.
2347
+ - [`cloudflare.md`](cloudflare.md) — tunnel setup OAuth flow that
2348
+ `/api/admin/cloudflare/setup` drives.
2349
+ - [`deployment.md`](deployment.md) — Pi setup; the brand-service / edge-
2350
+ service split that the health-vs-version table above relies on.
2351
+
2352
+ ---
2353
+ # Graph View
2354
+ Source: https://docs.getmaxy.com/graph.md
2355
+
2356
+ # Graph View
2357
+
2358
+ The **Graph** admin page (`/graph`) renders a force-directed view of your
2359
+ account's Neo4j subgraph. Labels on the canvas follow the zoom level, so you
2360
+ see the most useful identity at every scale.
2361
+
2362
+ ## Search and pivot
2363
+
2364
+ Type a term in the search box to highlight matching nodes on the canvas. Hits get an amber border so you can pick them out of a busy view. Click any highlighted node to open its side panel and pivot into its neighbourhood — both clicks (hit and non-hit) behave identically.
2365
+
2366
+ When a search is active and you click a node, the neighbourhood you pivot into is **narrowed to the search-relevant subset**. For example: searching "david" with 175 matches and clicking yourself returns the Davids you're connected to, not your entire LinkedIn graph. The narrowing applies once per pivot — clearing the search and pivoting again returns the full neighbourhood.
2367
+
2368
+ Searches reach **every textual property** of every operator-meaningful label, including denormalised fields the platform writes specifically so search can reach them — for example, the current job title of each LinkedIn connection (originally stored on the connection edge, copied to the Person node so the fulltext index can match it).
2369
+
2370
+ ## Conversation label tiers
2371
+
2372
+ Conversation nodes carry the most operator-meaningful identity in the
2373
+ subgraph (the conversation name or summary, the date it started, the message
2374
+ count). They render in one of three tiers, switched by canvas scale:
2375
+
2376
+ | Zoom | Label shape | Example |
2377
+ |------|-------------|---------|
2378
+ | Zoomed out (< 0.7×) | Compact — one line, capped at 24 characters. Preserves the no-overlap contract that matters when nodes are tightly packed. | `Maxyfi branding conflict…` |
2379
+ | Mid zoom (0.7× to 1.3×) | Wrapped — up to two lines of 24 characters each, soft-ellipsis on overflow. Full name is visible without hover. | `Maxyfi branding conflict` / `with Rubytech` |
2380
+ | Zoomed in (≥ 1.3×) | Detailed — wrapped name plus a metadata line reading `YYYY-MM-DD · N msgs`. | `Maxyfi branding conflict` / `with Rubytech` / `2026-04-23 · 7 msgs` |
2381
+
2382
+ Non-Conversation nodes (People, Messages, Tasks, WorkflowRuns, etc.) keep
2383
+ their concise single-line labels at every zoom level — the canvas stays
2384
+ readable when you zoom out to see a large subgraph.
2385
+
2386
+ Tier transitions are debounced so spinning the scroll wheel does not cause
2387
+ label flicker; labels only rewrite once zoom settles on a new tier.
2388
+
2389
+ ## Cluster-expand on Conversation/Message clicks (cluster-integrity fix)
2390
+
2391
+ Clicking a Conversation node OR any Message node pulls the WHOLE
2392
+ conversation cluster onto the canvas: the Conversation node itself plus
2393
+ every Message belonging to it (via `PART_OF`), capped at 200 messages
2394
+ for layout reasons. The arrow chain along the conversation (the `NEXT`
2395
+ edges) renders for free because the inter-node relationship pass picks
2396
+ up edges where both endpoints are in the visible window.
2397
+
2398
+ Pre-fix, clicking a middle Message expanded only its prev+next
2399
+ neighbours; the head, tail, and Conversation node dropped off, visually
2400
+ disintegrating the conversation. The new behaviour keeps the cluster
2401
+ intact across click navigation. `PART_OF` edges are now rendered between
2402
+ visible Conversation/Message pairs (previously suppressed because they
2403
+ "added no information when the Conversation node wasn't on canvas" — an
2404
+ assumption that broke the moment the cluster-expand put it there).
2405
+
2406
+ The breadcrumb above the canvas tracks each pivot — every entry except
2407
+ the last is clickable to pop the view-stack back to that point.
2408
+
2409
+ ## Tooltips and side panel
2410
+
2411
+ Hovering a node still shows the full 5-line tooltip (display name, labels,
2412
+ id, created at, updated at). Clicking a Conversation opens the side panel
2413
+ with the full property table — zoom-tier changes never alter these paths.
2414
+
2415
+ The side panel carries a **Trash** button for live nodes and a **Restore**
2416
+ button for trashed nodes. Soft-delete is reversible: trashed nodes
2417
+ remain in the graph and reappear when **Show trashed** is on.
2418
+
2419
+ ## Deleting a node
2420
+
2421
+ Two surfaces, same outcome:
2422
+
2423
+ - **Mouse (desktop):** drag a node to the dashed Trash zone in the upper-
2424
+ right corner of the canvas.
2425
+ - **Touch (mobile/tablet):** the dashed Trash zone is hidden because
2426
+ vis-network's drag hit-test never fires on touch. Tap the node to open
2427
+ the side panel, then tap **Trash**.
2428
+
2429
+ Both paths POST to the same soft-delete endpoint; the operator-side
2430
+ behaviour is identical.
2431
+
2432
+ ## Mobile layout
2433
+
2434
+ Below 640px viewport width the toolbar wraps: the search input claims
2435
+ its own row, the search-result slider claims its own row (full-width with
2436
+ an enlarged thumb for touch), and the Filter button + node count share
2437
+ the bottom row. The "← Back" control collapses to a left-arrow icon to
2438
+ preserve toolbar space at depth.
2439
+
2440
+ ## Trashed conversations
2441
+
2442
+ Trashed Conversation nodes are hidden by default. Toggle **Show trashed** in
2443
+ the filter popover to surface them; they render with a faded fill and dashed
2444
+ border, with their zoom-tier labels intact. The `N msgs` count excludes
2445
+ trashed Messages, so the detailed-tier label reflects only live turns in the
2446
+ conversation.
2447
+
2448
+ ## Filtering by channel and message kind
2449
+
2450
+ When you select **AdminConversation** or **PublicConversation** in the
2451
+ filter popover, two extra rows appear underneath the chip list:
2452
+
2453
+ - **Channel** — Web / WhatsApp. Select one to scope the canvas to
2454
+ conversations that came in over that channel only. Selecting both is
2455
+ the same as selecting neither (all channels). After the migration that
2456
+ ships with this release, every conversation carries an explicit
2457
+ channel value — pre-existing conversations are backfilled to "Web"
2458
+ because only the WhatsApp and Telegram intake paths ever set non-Web
2459
+ values.
2460
+ - **Message** — User / Assistant / WhatsApp. When you've also pivoted
2461
+ into a conversation neighbourhood (or your search hits messages
2462
+ directly), this row scopes the messages on canvas to the chosen kind.
2463
+ WhatsApp messages persist with their own sublabel so you can isolate
2464
+ the live-channel cohort from the agent-path cohort within the same
2465
+ conversation.
2466
+
2467
+ These sub-facets compose with the chip selection. Searching with the
2468
+ AdminConversation chip selected now also reaches the body text of every
2469
+ admin message — typing a rare word like "ATM" returns every conversation
2470
+ that mentions it, not just conversations with that word in the title.
2471
+
2472
+ ## Sidebar conversations list
2473
+
2474
+ The Recents list above the chat sidebar carries a per-row marker:
2475
+ WhatsApp conversations show a small WhatsApp glyph next to the
2476
+ conversation name. The dropdown above the list filters Recents to a
2477
+ specific channel — flipping it to **WhatsApp** hides web-chat
2478
+ conversations and vice versa.
2479
+
2480
+ ## Agents in the graph
2481
+
2482
+ Both admin and public agents appear as `:Agent` nodes in the graph. Open
2483
+ the **Agents** entry from the sidebar to see them all. Each agent
2484
+ carries a `:HANDLED_BY` edge from every conversation it has handled, so
2485
+ you can pivot from an agent to the conversations it ran. The admin
2486
+ agent's IDENTITY, SOUL, KNOWLEDGE, and KNOWLEDGE-SUMMARY documents
2487
+ appear as :KnowledgeDocument nodes connected via `HAS_*` edges, the same
2488
+ projection shape used for public agents.
2489
+
2490
+ ## Agent-execution telemetry
2491
+
2492
+ `ToolCall`, `StepResult`, `WorkflowStep`, and `WorkflowRun` nodes are
2493
+ agent-execution telemetry — kept for audit but noisy for day-to-day graph
2494
+ navigation (they make up roughly 9% of a typical brand's live nodes).
2495
+ They are hidden from `/graph` by default. To see them — for audit, debug,
2496
+ or tracing a specific agent run — open the filter popover and tick
2497
+ **Include agent actions** (the checkbox directly below **Show trashed**).
2498
+ Flipping it on surfaces the four labels as chips in the popover roster AND
2499
+ keeps them on the canvas when you pivot into a neighbourhood. The toggle
2500
+ is session-scoped: every new session starts with agent actions hidden, so
2501
+ the 90% domain-navigation path stays clean without having to remember to
2502
+ switch them off. Flipping it off again also drops them from any already-
2503
+ expanded neighbourhood so a click near a `ToolCall` does not re-introduce
2504
+ it.
2505
+
2506
+ ---
2507
+ # Neo4j Edge Types
2508
+ Source: https://docs.getmaxy.com/neo4j.md
2509
+
2510
+ # Neo4j edge types — operator reference
2511
+
2512
+ How Maxy's graph wires itself.
2513
+
2514
+ ## Typed-edge auto-extraction
2515
+
2516
+ At the end of every admin session (`/end`, `/archive`, `end session`, `archive this session`), the admin agent delegates one final pass to the `database-operator` specialist. That pass reads every prose-bearing node your account wrote since the last completed retrospective — messages, meetings, notes, pages, posts, reports, emails, ideas — and asks Claude Haiku to propose typed edges from the text. Only edges that match a closed allowlist of `(sourceLabel, EDGE_TYPE, targetLabel)` shapes are MERGEd into the graph. The graph wires itself; you do not have to ask for it.
2517
+
2518
+ If a session closes without one of the four typed end-intent tokens (Sidebar Archive, tab-close, power loss), the pass for that session is deferred — its prose nodes land at the next `/end`. Nothing is lost, just delayed by one session.
2519
+
2520
+ ## Typed-edge allowlist
2521
+
2522
+ <!-- TYPED-EDGE-TABLE:START -->
2523
+
2524
+ <!-- Generated by platform/plugins/memory/mcp/scripts/generate-edge-docs.ts from TYPED_EDGE_ALLOWLIST. Do not edit by hand. -->
2525
+
2526
+ | Source label | Edge type | Target label |
2527
+ |---|---|---|
2528
+ | Person | ATTENDED | Event |
2529
+ | Person | ATTENDED | Meeting |
2530
+ | Person | WORKS_AT | Organization |
2531
+ | Person | WORKS_AT | LocalBusiness |
2532
+ | Person | INVESTED_IN | Organization |
2533
+ | Person | INVESTED_IN | LocalBusiness |
2534
+ | Organization | INVESTED_IN | Organization |
2535
+ | Organization | INVESTED_IN | LocalBusiness |
2536
+ | Person | FOUNDED | Organization |
2537
+ | Person | FOUNDED | LocalBusiness |
2538
+ | Person | ADVISES | Organization |
2539
+ | Person | ADVISES | LocalBusiness |
2540
+ | Person | ADVISES | Person |
2541
+ | Message | MENTIONS | Person |
2542
+ | Message | MENTIONS | Organization |
2543
+ | Message | MENTIONS | LocalBusiness |
2544
+ | Message | MENTIONS | Event |
2545
+ | Page | MENTIONS | Person |
2546
+ | Page | MENTIONS | Organization |
2547
+ | Page | MENTIONS | LocalBusiness |
2548
+ | Page | MENTIONS | Event |
2549
+ | Meeting | MENTIONS | Person |
2550
+ | Meeting | MENTIONS | Organization |
2551
+ | KnowledgeDocument | MENTIONS | Person |
2552
+ | KnowledgeDocument | MENTIONS | Organization |
2553
+ | Note | MENTIONS | Person |
2554
+ | Note | MENTIONS | Organization |
2555
+ | Idea | MENTIONS | Person |
2556
+ | Idea | MENTIONS | Organization |
2557
+ | Post | MENTIONS | Person |
2558
+ | Post | MENTIONS | Organization |
2559
+ | Report | MENTIONS | Person |
2560
+ | Report | MENTIONS | Organization |
2561
+ | Person | AUTHORED | Post |
2562
+ | Person | AUTHORED | Report |
2563
+ | Person | AUTHORED | Page |
2564
+ | Person | AUTHORED | Note |
2565
+ | KnowledgeDocument | ATTACHED_TO | Meeting |
2566
+ | Page | ATTACHED_TO | Project |
2567
+ | Note | ATTACHED_TO | Project |
2568
+ | Page | REFERENCES | Page |
2569
+ | Report | REFERENCES | Report |
2570
+ | Report | REFERENCES | Page |
2571
+
2572
+ <!-- TYPED-EDGE-TABLE:END -->
2573
+
2574
+ ---
2575
+ # Platform Internals
2576
+ Source: https://docs.getmaxy.com/internals.md
2577
+
2578
+ # Platform Internals — Retrieval Architecture
2579
+
2580
+ Technical architecture reference for the retrieval pipeline, knowledge delivery, and supporting infrastructure. This document covers how information moves from the Neo4j graph into an agent's context — the mechanics behind "Maxy searches this graph to retrieve relevant context."
2581
+
2582
+ Use this reference when assessing capabilities, diagnosing retrieval behaviour, or answering questions about how the platform works internally. When a question asks "does Maxy have X?" — check here before asserting a gap.
2583
+
2584
+ ---
2585
+
2586
+ ## Retrieval Pipeline Overview
2587
+
2588
+ Every knowledge query flows through a hybrid search pipeline that combines semantic similarity with keyword matching, applies layered access controls, expands results via graph traversal, and optionally re-ranks via LLM reasoning.
2589
+
2590
+ ```
2591
+ QUERY ── (retrievalClass from Task 304 gateway-classifier)
2592
+
2593
+ ├── EXPAND (Haiku — 3-5 paraphrases, 1h cache) [flag: MAXY_GS_EXPANSION]
2594
+
2595
+ ├── ROUTE (per-class label filter + fusion weights) [flag: MAXY_GS_ROUTE]
2596
+
2597
+ ├── For each query ────► EMBED ──► VECTOR SEARCH ──┐
2598
+ │ ├─► FUSE (weighted-sum or RRF) [flag: MAXY_GS_RRF]
2599
+ │ └────► BM25 FULL-TEXT ──────────┘
2600
+ │ (entity_search — universal coverage)
2601
+
2602
+ ├── BOOST (compiledTruth +15%, backlinks log 5-25%) [flag: MAXY_GS_BOOSTS]
2603
+ ├── DEDUP (4 layers: nodeId, slug, canonicalName, hash) [flag: MAXY_GS_DEDUP]
2604
+ ├── THRESHOLD + SORT + SLICE
2605
+ └── GRAPH EXPAND ──► RESULTS
2606
+
2607
+ Fusion (default / weighted-sum): combined = 0.7 × vector + 0.3 × bm25_norm
2608
+ Fusion (RRF): score = Σ 1 / (60 + rank_i) across ranked lists
2609
+ Fallback: if the full-text index doesn't exist, vector-only results are returned (graceful degradation, no error).
2610
+ ```
2611
+
2612
+ Each Task 308 enhancement is independently flagged. All flags default OFF — the unflagged pipeline is identical to the baseline weighted-sum + nodeId-only-dedup behaviour. Tasks 305 (typed-edge backlinks) and 306 (compiledTruth property) have landed, so the boost data is populated; flag activation, soak windows, and per-flag measurement live under Task 337.
2613
+
2614
+ ### Hybrid Search Detail
2615
+
2616
+ **Vector path:** The query is embedded via Ollama (model per `EMBED_MODEL` env var, default `nomic-embed-text`). The resulting vector is compared against Neo4j's HNSW cosine indexes — one per indexed label. Dimensions are configured at install time (default 768). The search runs against all discovered indexes (or a subset if the caller specifies label filters). Scores are in [0, 1] (cosine similarity).
2617
+
2618
+ **BM25 path:** The raw query text is escaped for Lucene special characters and run against the `entity_search` full-text index (earlier platform fixes — universal coverage), which spans every operator-meaningful label written by the platform on the canonical text-property union (~28 properties: `name`, `firstName`, `lastName`, `givenName`, `familyName`, `title`, `summary`, `body`, `content`, `description`, `headline`, `email`, `subject`, `bodyPreview`, etc.). Pre-Task-748 the index was named `knowledge_fulltext` and covered only `KnowledgeDocument | Section | Chunk` — that gap silently hid Person/Organization/Task/Event/etc. from BM25 regardless of query. Raw BM25 scores are in [0, infinity) — they are normalised to [0, 1] via min-max scaling within the result set before merging. When all scores are equal (or a single result), all normalise to 1.0.
2619
+
2620
+ **Merge:** Results from both paths are collected in a single map keyed by `nodeId`. A node appearing in both paths accumulates the max vector score and max BM25 score independently. The combined score is `0.7 * vectorScore + 0.3 * bm25Score`. Results are sorted descending by combined score, then sliced to the requested limit (default 10).
2621
+
2622
+ ### Task 308 enhancements (flagged, default off)
2623
+
2624
+ | Stage | Module | Flag | What it does |
2625
+ |---|---|---|---|
2626
+ | Routing | `route.ts` | `MAXY_GS_ROUTE` | Picks per-class label filter + fusion weights from the `retrievalClass` hint produced by Task 304's gateway-classifier. `entity` → vector-heavy + Person/Company/Concept; `temporal` → BM25-heavy over Event; `event` → BM25-only over Event; `general` → balanced; `none` → skip the lookup. |
2627
+ | Multi-query expansion | `query-expansion.ts` | `MAXY_GS_EXPANSION` | Haiku generates 3-5 paraphrases per query; each runs through vector + BM25 in parallel, with results unioned before fusion. Per-call 1-hour cache keyed by (accountId, query, retrievalClass). Graceful degrade on Haiku failure — original query only. |
2628
+ | RRF fusion | `rrf-fusion.ts` | `MAXY_GS_RRF` | Replaces weighted-sum with Reciprocal Rank Fusion (k=60 by default). Sums `1 / (k + rank)` per node across the ranked lists each pass produces. More robust to score-distribution drift between indexes than weighted-sum. Weighted-sum stays as the fallback. |
2629
+ | compiledTruth boost | `boosts.ts` | `MAXY_GS_BOOSTS` | +15% to the combined score of any hit whose node carries a non-null `compiledTruth` property (populated by Task 306 on Person/Company/Concept). The property is in the `entity_search` index so BM25 hits against summary text are also matched. |
2630
+ | Backlink boost | `boosts.ts` | `MAXY_GS_BOOSTS` | `bump = clamp(0.05 + 0.05 × log10(backlinkCount), 0.05, 0.25)`. 1 backlink → +5%; 10 → +10%; 100 → +15%; 1000+ → +20%; capped at +25%. Reads `backlinkCount` populated by Task 305's typed-edge hook. |
2631
+ | 4-layer dedup | `dedup.ts` | `MAXY_GS_DEDUP` | Strict superset of nodeId-only dedup. Layers: `nodeId`, `slug`, `canonicalName` (case-insensitive, falls back to `name`), `contentHash` (sha256 of `compiledTruth || content`). Highest-score representative wins per collision class. Missing keys skip the layer, no false collision. |
2632
+
2633
+ A per-call log line lets the operator see which stages ran with which counts:
2634
+
2635
+ ```
2636
+ [graph-search:hybrid] accountId=<8c> retrievalClass=<c> expansions=<n> vector=<n> bm25=<n> fused=<n> boosted=<n> deduped=<n> final=<n> mode=<hybrid|rrf|bm25> ms=<ms> expand-ms=<ms>
2637
+ ```
2638
+
2639
+ ### What the hybrid approach catches
2640
+
2641
+ Vector search excels at semantic meaning — "how do I contact someone" finds nodes about communication even if the word "contact" doesn't appear. BM25 excels at exact terms — invoice numbers, product codes, proper nouns, technical identifiers. The hybrid combination ensures both modes contribute, with semantic similarity weighted higher (0.7) because most user queries are natural language.
2642
+
2643
+ ---
2644
+
2645
+ ## Embedding Infrastructure
2646
+
2647
+ | Property | Value |
2648
+ |---|---|
2649
+ | Model | Default `nomic-embed-text` (via Ollama at `localhost:11434`), configurable at install time via `--embed-model` |
2650
+ | Dimensions | Default 768, configurable at install time (resolved from model lookup table or `--embed-dimensions`) |
2651
+ | Similarity function | Cosine |
2652
+ | Index algorithm | HNSW (approximate nearest-neighbor) |
2653
+ | Configurable via | `EMBED_MODEL` and `EMBED_DIMENSIONS` env vars (set by installer in `~/{configDir}/.env`), `OLLAMA_URL` |
2654
+
2655
+ ### Indexed node labels
2656
+
2657
+ Every searchable node type has its own vector index. The `memory-search` tool discovers indexes at runtime via `SHOW INDEXES` and caches the label-to-index mapping. This means new index definitions in `schema.cypher` become searchable automatically without code changes.
2658
+
2659
+ Indexed labels: `Question`, `DefinedTerm`, `Review`, `Service`, `Person`, `LocalBusiness`, `PriceSpecification`, `Task`, `CreativeWork`, `DigitalDocument`, `KnowledgeDocument` (includes email threads via `source:'email'` since Task 321), `Section`, `Chunk`, `Conversation`, `Message`, `Event`, `Workflow`, `Preference` (18 labels total).
2660
+
2661
+ ### Full-text index
2662
+
2663
+ | Index name | Labels | Properties | Purpose |
2664
+ |---|---|---|---|
2665
+ | `entity_search` | All operator-meaningful labels (~40, see [`schema.cypher`](../../../neo4j/schema.cypher)) | Canonical text-property union (~28) | Universal BM25 keyword matching across the whole graph |
2666
+
2667
+ ### Embedding lifecycle
2668
+
2669
+ Embeddings are computed when nodes are created or updated (via `memory-write`, `memory-ingest`, or any tool that persists to Neo4j). If Ollama is unavailable at write time, nodes are stored without embeddings. The `memory-reindex` tool backfills missing embeddings by iterating nodes where `embedding IS NULL`, calling Ollama's `/api/embed` endpoint, and storing the resulting vector. Batch embedding is supported for efficiency.
2670
+
2671
+ ---
2672
+
2673
+ ## Knowledge Document Hierarchy
2674
+
2675
+ Large documents are decomposed into a three-level hierarchy for granular retrieval:
2676
+
2677
+ ```
2678
+ KnowledgeDocument
2679
+ ├── summary (embedded) — document-level semantic anchor
2680
+ ├── Section
2681
+ │ ├── summary (embedded) — section-level semantic anchor
2682
+ │ └── Chunk
2683
+ │ ├── summary (embedded) — chunk-level semantic anchor
2684
+ │ └── content (raw text, BM25-indexed) — full content for retrieval
2685
+ └── attachmentId — links back to the source file
2686
+ ```
2687
+
2688
+ All three levels are independently vector-indexed and BM25-indexed. A query may match at the document level (broad topic), section level (sub-topic), or chunk level (specific passage). Graph expansion from a matched chunk retrieves its parent section and document for context.
2689
+
2690
+ ### Semantic chunking
2691
+
2692
+ Documents are split by a semantic chunker that identifies topic boundaries rather than using fixed character counts. Each chunk gets a summary (used for embedding) and retains the raw content (used for BM25 and for returning to the agent).
2693
+
2694
+ ---
2695
+
2696
+ ## Response-side `fields` projection
2697
+
2698
+ `memory-search` accepts an optional `fields: string[]` that narrows the
2699
+ `properties` returned on each row to the caller-named keys. This is a
2700
+ read-side payload trim only — it runs **after** `hybrid()` returns, so
2701
+ vector search, BM25, keyword subscriptions, and graph expansion all see
2702
+ the full text. Ranking does not change.
2703
+
2704
+ - `fields` omitted → today's behaviour (every property except `embedding`).
2705
+ - `fields: ["name", "slug"]` → only those keys per row.
2706
+ - `fields: []` → empty `properties` object — explicit "no properties".
2707
+ - Unknown keys are silently skipped. Rows lacking a requested key omit it
2708
+ on that row.
2709
+ - `related[*].properties` is NOT projected (separate concern).
2710
+
2711
+ Use this when the caller knows which keys it needs (slug → name, Person →
2712
+ phoneNumber). It is the safe alternative to write-time summarisation,
2713
+ which is lossy: write-time pruning has no way to know which keys a future
2714
+ query will want.
2715
+
2716
+ Observability: when `fields` is set, `memory-search.ts` writes
2717
+ `[memory-search] accountId=… fields=… returnedProps=N droppedProps=N` to
2718
+ stderr. `droppedProps=0` across many calls with `fields` set is a
2719
+ diagnostic signal — either the schema has already been narrowed upstream,
2720
+ or callers are requesting every field and defeating the purpose.
2721
+
2722
+ ## Guard Layers
2723
+
2724
+ Every query path — vector search, BM25 search, keyword subscriptions, and graph expansion — applies a consistent set of access control filters. These are Cypher WHERE clauses, not middleware, so they cannot be bypassed by tool parameter manipulation.
2725
+
2726
+ ### Layer 1: Soft-delete filter
2727
+
2728
+ ```
2729
+ WHERE node.deletedAt IS NULL
2730
+ ```
2731
+
2732
+ Unconditional. No parameter controls it. Nodes with a `deletedAt` timestamp are excluded from all query paths. Soft-deleted `KnowledgeDocument` nodes cascade the timestamp to all child `Section` and `Chunk` nodes. Grace period before hard deletion: 7 days. Re-ingesting a soft-deleted document (same `attachmentId`) clears `deletedAt` and replaces the hierarchy.
2733
+
2734
+ ### Layer 2: Scope filter
2735
+
2736
+ ```
2737
+ WHERE (node.scope IS NULL OR node.scope IN $allowedScopes)
2738
+ ```
2739
+
2740
+ When `allowedScopes` is set (e.g., `["public", "shared"]` for public agents), only nodes with a matching `scope` property — or no scope at all (legacy transitional safety) — are returned. When `allowedScopes` is omitted (admin agent), no scope filtering is applied. Scope values: `public`, `shared`, `admin`.
2741
+
2742
+ ### Layer 3: Per-agent tag filter
2743
+
2744
+ ```
2745
+ WHERE node.agents IS NOT NULL AND $agentSlug IN node.agents
2746
+ ```
2747
+
2748
+ When `agentSlug` is set (public agent queries), only nodes explicitly tagged for that agent are returned. The `agents` property is a string array on each node — a node is visible to an agent only if the agent's slug appears in this array. No implicit "available to all" fallback. This is enforced at the MCP server level via the `AGENT_SLUG` environment variable — tool parameter overrides are rejected when the env var is set.
2749
+
2750
+ **Defense in depth:** Both scope and agent filters must pass. An admin-scoped node tagged for a public agent is still invisible to that agent because the scope filter rejects it first.
2751
+
2752
+ ### Layer 4: Graph expansion enforcement
2753
+
2754
+ Related nodes discovered during hop traversal are independently filtered:
2755
+
2756
+ ```
2757
+ WHERE (related.scope IS NULL OR related.scope IN $allowedScopes)
2758
+ AND (related.agents IS NULL OR $agentSlug IN related.agents)
2759
+ AND related.deletedAt IS NULL
2760
+ ```
2761
+
2762
+ This prevents cross-agent content leakage via graph traversal — a public agent cannot reach admin-scoped nodes by following relationships from a public node. Untagged related nodes (no `agents` property) pass through, allowing shared structural nodes (e.g., `PriceSpecification` linked to a `Service`) to be discoverable.
2763
+
2764
+ ### Layer 5: Account isolation
2765
+
2766
+ ```
2767
+ WHERE node.accountId = $accountId
2768
+ ```
2769
+
2770
+ Multi-tenancy boundary. Every query is scoped to the requesting account. The `ACCOUNT_ID` environment variable is set at MCP server startup — it is not a tool parameter and cannot be overridden by the agent.
2771
+
2772
+ The read filter alone is not sufficient — it correctly *hides* alien-account nodes from every UI but does not prevent them existing. A writer that misresolves `accountId` (literal, undefined, or inferred-from-the-wrong-context) leaks nodes into the graph with no downstream symptom; the read filter then keeps them invisible indefinitely. The write-side doctrine is documented in `.docs/neo4j.md` "Account isolation invariant" — every writer that stamps `n.accountId` must verify the value against `${DATA_ROOT}/accounts/<id>/account.json` before write. The live floor is `writeNodeWithEdges` — every doctrine-primitive write is gated by an `accountId == process.env.ACCOUNT_ID` check (the spawning process validates `ACCOUNT_ID` at boot against the on-disk account set via the `account-enumeration` lib), with `[graph-write] reject reason=invalid-account-id …` as the rejection signal.
2773
+
2774
+ **Two boot-time surfaces stamp + validate the env** (added 2026-05-07). The brand systemd unit emits `Environment=ACCOUNT_ID=<uuid>` (resolved by the installer from `INSTALL_DIR/data/accounts/<uuid>/account.json`); the Hono boot path then calls `validateAccountIdEnv` against the on-disk set and emits `[graph-health] account-id-env present=true id=<8> matches-on-disk=true` on success or `[graph-health] account-id-env FATAL reason=<missing|no-on-disk-account|mismatch>` + `process.exit(1)` on failure. No fallback — a misconfigured Pi cannot silently boot.
2775
+
2776
+ ---
2777
+
2778
+ ## Query Classification
2779
+
2780
+ Before searching, a Haiku classifier decides whether a query needs knowledge retrieval at all. This prevents meta-queries ("hello", "thanks", "continue") from polluting the system prompt with irrelevant search results.
2781
+
2782
+ | Property | Admin variant | Public variant |
2783
+ |---|---|---|
2784
+ | Model | `claude-haiku-4-5` | Same |
2785
+ | Timeout | 3 seconds | Same |
2786
+ | History window | Last 4 messages (2 user + 2 assistant) | Same |
2787
+ | Max tokens | 200 | 120 |
2788
+ | Query rewriting | Yes — resolves references from history into concrete search terms | Same |
2789
+ | Topic-change detection | Yes — detects shifts with confidence score | No (removed, earlier platform fixes) |
2790
+ | Fallback on failure | `search: true` (always search with raw message) | Same |
2791
+
2792
+ ### Classification output
2793
+
2794
+ The classifier returns a JSON object:
2795
+ - `search` (boolean) — whether a knowledge search should run
2796
+ - `query` (string or null) — a search-optimised rewrite of the user's message, or null to use the raw message
2797
+ - `reason` (string) — short explanation of the decision
2798
+
2799
+ When `search` is true and `query` is non-null, the rewritten query replaces the raw message for the memory-search call. This is important: the classifier resolves pronouns and references from conversation history into concrete terms, improving retrieval precision.
2800
+
2801
+ ### Knowledge retrieval gate
2802
+
2803
+ On the public PTY surface the agent itself decides when to call `memory-search` — there is no server-side classifier interposed between the user message and the agent's first tool call. KNOWLEDGE.md (when present) is assembled into the agent's system prompt at spawn time. Whether `memory-search` is reachable at message time is controlled by the agent's `liveMemory` config flag: when `true`, the per-spawn allowlist includes `memory-search` and reads run with `ALLOWED_SCOPES=public`; when `false`, the agent has no graph access mid-turn.
2804
+
2805
+ ### Observability
2806
+
2807
+ Admin: `[admin-query-classifier]` log line with `topicChange`, `topicChangeConfidence`, `existingTopic`, `latencyMs`.
2808
+
2809
+ Public: `[public-query-classifier]` log line with `search`, `effectiveQuery`, `reason`, `latencyMs`. The intentional absence of topic-change fields in the public log is the on-disk evidence that the public path does less work.
2810
+
2811
+ ---
2812
+ ---
2813
+
2814
+ ## Reports — durable workflow output (Task 332)
2815
+
2816
+ The `:Report` label is the platform's durable shape for workflow output the operator may want back later — daily briefings, dream cycle runs, ad-hoc analyses. Three MCP tools own the surface, all on the memory plugin:
2817
+
2818
+ - `memory-report-write` — append-only writer. Validates body ≤ 10,000 chars, embeds title+body, and CREATEs a `:Report` node. Idempotent on `(accountId, title, occurredAt-within-same-minute)` — a second call with the same title in the same minute returns the existing node instead of duplicating. Parented to the active `:Conversation` via `:PRODUCED` when `SESSION_NODE_ID` is set (the chat-driven default); falls back to the account's `:AdminUser` so the graph-hierarchy doctrine holds even outside a conversation.
2819
+ - `memory-report-read-latest` — fetches the newest `:Report` (default `limit=1`) tagged with a given keyword. The expected route for any operator phrasing of "latest X", "last night's X", "show me X report".
2820
+ - `memory-report-list` — metadata-only paginated listing (newest first), with optional `keyword` and `sourceWorkflow` filters. Use to scan the catalogue without paying for full bodies.
2821
+
2822
+ Every operation emits one log line: `[reports] op=<write|read-latest|list> reportId=<short> keywords=<csv> ms=<n>` (with `idempotent=1` on a write that resolved to an existing node, `hits=<n>` on reads, `total=<n>` on list).
2823
+
2824
+ Routing is not classifier-side. The admin agent's `IDENTITY.md` carries the rule under **Recalling reports**: "latest <X>" / "last night's <X>" / "show me <X> report" → first tool call is `memory-report-read-latest`. The intent classifier (Task 304's `retrievalClass`) already differentiates temporal vs entity vs event reads; reports route off the literal phrase, not a new class.
2825
+
2826
+ The first caller is the `briefing` skill (`platform/plugins/scheduling/skills/briefing/SKILL.md`), which persists each run as a `:Report` with `title: "Daily briefing <YYYY-MM-DD>"`, `keywords: ["daily-briefing", "<YYYY-MM-DD>"]`, `sourceWorkflow: "daily-briefing"`. Dream-cycle (Task 327) and ad-hoc analyses are expected to follow the same pattern.
2827
+
2828
+ ---
2829
+
2830
+ ## Graph Expansion
2831
+
2832
+ After the top results are selected (by combined score or by LLM ranking), each result node is expanded by traversing its immediate relationships.
2833
+
2834
+ ### Traversal mechanics
2835
+
2836
+ ```cypher
2837
+ MATCH (n)-[r]-(related)
2838
+ WHERE elementId(n) = $nodeId
2839
+ AND related.deletedAt IS NULL
2840
+ AND (related.scope IS NULL OR related.scope IN $allowedScopes)
2841
+ AND (related.agents IS NULL OR $agentSlug IN related.agents)
2842
+ RETURN type(r), direction, labels(related), related
2843
+ LIMIT 20
2844
+ ```
2845
+
2846
+ - **Default hop depth:** 1 (immediate relationships only)
2847
+ - **Related nodes cap:** 20 per primary result
2848
+ - **Direction tracking:** Each relationship is labelled `outgoing` or `incoming`
2849
+ - **Scope enforcement:** All guard layers (soft-delete, scope, agent) apply to related nodes
2850
+ - **Configurable:** `expandHops: 0` produces compact output (properties only, no related nodes) — useful for listing/inventory queries
2851
+
2852
+ ### What expansion provides
2853
+
2854
+ A `Service` node matched by vector search will have its `PriceSpecification`, `Review` nodes, and parent `LocalBusiness` attached as related nodes. A `Chunk` matched by BM25 will have its parent `Section` and `KnowledgeDocument`. This context enrichment means the agent receives not just the matched node but its immediate neighbourhood in the graph.
2855
+
2856
+ ---
2857
+
2858
+ ## Keyword Subscriptions — Reactive Per-Agent Knowledge
2859
+
2860
+ Each public agent can subscribe to up to 5 keywords via `knowledgeKeywords` in its `config.json`. These subscriptions make the agent reactive to new graph content matching its topics — content added after the agent was created becomes discoverable without manual tag updates.
2861
+
2862
+ ### Dual search per keyword
2863
+
2864
+ For each subscription keyword, two complementary searches run:
2865
+
2866
+ 1. **BM25 full-text search** — queries the universal `entity_search` index with the keyword as the search term. Catches content that mentions the keyword in its text across every operator-meaningful label.
2867
+
2868
+ 2. **Property-based search** — finds nodes whose `keywords` array property contains the subscription keyword (case-insensitive). Catches nodes explicitly tagged with that keyword topic. These matches are boosted to maximum BM25 score (1.0) since they are exact tag matches.
2869
+
2870
+ Both searches run **without** the per-agent tag filter (`agentSlug`) — keyword subscriptions are scope-inclusive by design, meaning an agent's subscriptions can discover content not directly tagged for it. The scope filter (`allowedScopes`) still applies as defense in depth — admin-only content remains invisible to public agents regardless of keyword matches.
2871
+
2872
+ ### Union semantics
2873
+
2874
+ Results from keyword subscription searches are merged into the same scored map as the primary vector+BM25 results. Deduplication by `nodeId` with `Math.max` on scores means a node found by both direct search and keyword subscription keeps the highest score from each method.
2875
+
2876
+ ### Lifecycle
2877
+
2878
+ Keywords are consumed by the `update-knowledge` admin skill when regenerating KNOWLEDGE.md — the regeneration query broadens the operator-tagged set with keyword matches so newly-added graph content that shares a subscribed topic lands in the next baked snapshot. There is no runtime keyword-injection path on the public PTY surface.
2879
+
2880
+ ---
2881
+
2882
+ ## Conversation Search
2883
+
2884
+ Separate from the knowledge retrieval pipeline, `conversation-search` provides semantic search over past messages.
2885
+
2886
+ - **Index:** `message_embedding` (768-dim cosine HNSW on `Message` nodes)
2887
+ - **Scope:** When `SESSION_ID` is set (public agent), results are limited to that conversation. Admin searches all conversations.
2888
+ - **Output:** Messages with role, content, timestamp, and relevance score.
2889
+
2890
+ This tool is read-only and available to both public and admin agents.
2891
+
2892
+ ### When conversations are created
2893
+
2894
+ `:Conversation` nodes on webchat (admin login, "New conversation" in the burger, a new public visitor) are created lazily. Opening the chat or logging in does not write anything to the graph — Maxy only records the conversation once the user sends a second message. This keeps `conversation-search` and the Conversations modal free of one-turn abandoned threads. WhatsApp and Telegram take the opposite posture: every inbound — DM or group, allowed or activation-off, agent-invoked or gated — MERGEs the `:Conversation` and writes a forensic `:Message:WhatsAppMessage` row before any access-control decision. The graph is the durable record of every message the device received, not just the ones the agent replied to. See `.docs/web-chat.md` "Deferred conversation persistence" and `.docs/whatsapp.md` "Session continuity" for the full contract.
2895
+
2896
+ Each row in the Conversations modal exposes a `View logs` row-action that opens a popover with three links — **Stream**, **Errors**, **SSE** — each of which targets `/api/admin/logs?type={stream|error|sse}&sessionId={full-id}` in a new tab. The row's 8-char id chip is click-to-copy; hover reveals the full `sessionId` as a tooltip. See `.docs/web-chat.md` "In-chat retrieval" for the route contract and `console.debug` observability.
2897
+
2898
+ ### Static publish surface — `/sites/*`
2899
+
2900
+ Maxy hosts a generic per-account static-tree publish surface at `https://public.<brand>/sites/<...>/<file>`. The route serves files from `<accountDir>/sites/<...>` with URL=disk mirroring — operator drops the tree on disk, no upload API. Extended MIME covers HTML/CSS/JS/woff2/fonts on top of images. Path traversal (`..`, encoded `..`, segments failing `SAFE_SEG_RE`) returns 403; symlinks escaping the sites root are rejected via a `realpathSync` re-check. `.html` responses carry `Content-Security-Policy: default-src 'self' https: data:; script-src 'none'` and `Cache-Control: no-cache`; assets are cached for an hour; every response carries `X-Content-Type-Options: nosniff`. Per-account isolation comes from `resolveAccount` — every brand's install sees only its own tree.
2901
+
2902
+ **Directory canonicalisation.** A request whose disk target is a directory is `301`'d to the trailing-slash form (query string preserved) before any body is served — RFC 3986 §5.3 base resolution requires the trailing slash so relative refs in the served HTML resolve under the directory, not its parent. After the redirect the route serves `<dir>/index.html` if it exists on disk; otherwise `404`. There is **no** implicit-`index.html` invention for missing paths — the publisher owns canonical URLs. A brochure shipped without `index.html` is reached at `/sites/<slug>/<file>.html`, and the admin skill `publish-site` is the sanctioned surface that moves the extracted tree under `<accountDir>/sites/<slug>/` and emits the canonical path slug. Operator-side: drop a brochure at `<accountDir>/sites/properties/<id>/brochure/output/` and it serves at `<public-host>/sites/properties/<id>/brochure/output/brochure.html` (or `<public-host>/sites/properties/<id>/brochure/output/` if that directory contains an `index.html`). See `.docs/web-chat.md` `/sites/*` route entry for the wire contract and `[sites]` log lines (`serve|redirect-trailing-slash|not-found|path-traversal-rejected|symlink-escape-rejected|no-account`).
2903
+
2904
+ **Deterministic public-hostname surface.** The `<public-host>` half of the URL the operator pastes is resolved by the `mcp__plugin_admin_admin__public-hostname` MCP tool. It reads `<configDir>/cloudflared/config.yml` (ingress list) then falls back to `<configDir>/alias-domains.json` — the same two files `cloudflared` and `platform/ui/server/index.ts`'s `isPublicHost()` already trust to route. Returns `{hostname, isApex, source}` on hit (`source` is `"cloudflared-config.yml"` or `"alias-domains.json"`), or `{hostname:null, source:null, reason:"no-tunnel"}` on miss. Tiebreak: apex wins over subdomain (single-label, or `www.<apex>` stripped). `publish-site` step 6 calls it after the move and emits the full URL (`https://<hostname><path-slug>`) in the same turn. Graph queries are no longer involved — any earlier graph-backed resolver returned `(none)` on accounts bootstrapped without `cloudflare-task-tracker.ts` writes (laptop Real Agent, manual `cloudflared` setup), the `llm-framing-deterministic` recurrence class. The graph-mcp shim additionally runs a sequential envelope-warning probe on every read response — when Neo4j emits `gql_status` codes matching `^0[12]N5\d$` (e.g. `01N52` "property does not exist"), the shim stitches them into a prefix content block on the response so property-name misses surface to the agent inline instead of returning silent `[]`. Probe failure is best-effort: the upstream response forwards unchanged with `[mcp:graph] probe-error`.
2905
+
2906
+ ### Cross-tab session rotation
2907
+
2908
+ When you click "New conversation" in the chat tab, Maxy mints a fresh admin session key on the server and clears the old one. Sibling admin tabs (`/graph`, `/data`) opened in the same browser keep working without re-login: the chat tab broadcasts the new key on a same-origin channel so each sibling tab updates its captured key instantly, and any in-flight admin request that 401s with the rotation-orphan code retries once after re-reading the latest key from per-tab storage. If neither path recovers (browser locked down, second 401 after retry, session expired), the tab shows a single banner — "Your admin session was renewed in another tab. Click to reload." — and one click sends you back through login. No silent 401s; no re-clicking through the same trash icon hoping it sticks. See `.docs/web-chat.md` "Cross-tab rotation contract" for the wire-level `code` taxonomy and observability surfaces.
2909
+
2910
+ ---
2911
+
2912
+ ## Context Assembly — How Retrieved Knowledge Reaches the Agent
2913
+
2914
+ The final step in the retrieval pipeline is injecting retrieved content into the agent's system prompt. The path depends on agent configuration.
2915
+
2916
+ ### Public agent paths
2917
+
2918
+ Public agents run on the same native Claude Code PTY surface as the admin, dispatched through the channel PTY-bridge with `role: 'public'`. The agent's directory files (IDENTITY.md, SOUL.md, KNOWLEDGE.md, KNOWLEDGE-SUMMARY.md when present) are assembled into the system prompt at spawn time. There is no per-turn server-side knowledge injection.
2919
+
2920
+ Two paths, selected by the agent's `liveMemory` config flag:
2921
+
2922
+ - **`liveMemory: false`** — `memory-search` is excluded from the per-spawn `--allowed-tools` allowlist. The agent has no graph access mid-conversation; KNOWLEDGE.md is the ceiling of factual knowledge.
2923
+ - **`liveMemory: true`** — `memory-search` is in the allowlist. The agent decides at message time whether to call it; reads run against the graph with `ALLOWED_SCOPES=public` so only public-scoped nodes return. KNOWLEDGE.md and the live `memory-search` surface are complementary — the baked file covers evergreen facts; the live tool covers the long-tail public-scoped lookups.
2924
+
2925
+ ### KNOWLEDGE.md staleness guard
2926
+
2927
+ When both `KNOWLEDGE.md` and `KNOWLEDGE-SUMMARY.md` exist, the server compares modification times. If KNOWLEDGE.md is newer than the summary (summary is stale), the full KNOWLEDGE.md is used. Otherwise, the summary is preferred (smaller token footprint).
2928
+
2929
+ ### Admin agent path
2930
+
2931
+ The admin agent runs via Claude Code CLI, which manages its own system prompt assembly. Knowledge reaches the admin agent through MCP tools — `memory-search` is the read-path entry point (server-side LLM ranking was removed by Task 424; the agent ranks in-turn against any criterion). The admin agent also receives session context via `loadSessionContext`, which injects:
2932
+
2933
+ - Recent review digest (last public chat or review digest `CreativeWork`)
2934
+ - Open tasks (priority-ordered, capped)
2935
+ - Active review alerts (unsuppressed, last 24 hours, capped at 5)
2936
+
2937
+ This is assembled as a `<previous-context>` block in the system prompt on each admin turn.
2938
+
2939
+ ### fetchMemoryContext — the MCP bridge
2940
+
2941
+ For public agents, the server calls the memory MCP server via JSON-RPC over stdin/stdout:
2942
+
2943
+ 1. Spawn the memory MCP server as a subprocess with environment variables: `ACCOUNT_ID`, `ALLOWED_SCOPES=public,shared`, `AGENT_SLUG`, `KNOWLEDGE_KEYWORDS`, `SESSION_ID`
2944
+ 2. Send `initialize` + `tools/call` (name: `memory-search`, arguments: `{query, account_id}`)
2945
+ 3. Read the tool result text
2946
+ 4. Timeout: 8 seconds. On any failure, returns null — the agent proceeds without memory context.
2947
+
2948
+ This subprocess model means each public agent query gets an isolated, short-lived memory server instance with the correct scope constraints baked into its environment.
2949
+
2950
+ ---
2951
+
2952
+ ## Output Formatting and Budget
2953
+
2954
+ The `memory-search` tool formats results as structured text with labels, properties, scores, and related nodes. An output character budget of 80,000 characters prevents results from exceeding Claude Code's tool result token limit (~100K chars). When results exceed the budget, related nodes are progressively dropped (compact mode) to fit within the limit.
2955
+
2956
+ Each result is formatted as:
2957
+ ```
2958
+ [Label1, Label2] (id: nodeId) (score: 0.XXX)
2959
+ property1: value
2960
+ property2: value
2961
+ Related:
2962
+ --[RELATIONSHIP]--> [RelatedLabel] {prop1: val, prop2: val}
2963
+ <--[RELATIONSHIP]-- [RelatedLabel] {prop1: val, prop2: val}
2964
+ ```
2965
+
2966
+ Results are separated by `---` dividers. The `embedding` and `accountId` properties are stripped from output (internal fields, not useful to the agent).
2967
+
2968
+ ---
2969
+
2970
+ ## Index Discovery and Schema Evolution
2971
+
2972
+ The memory MCP server does not hardcode index names. On first query, it runs `SHOW INDEXES YIELD name, labelsOrTypes, type WHERE type = 'VECTOR'` and builds a label-to-index-name map. This map is cached for the lifetime of the process.
2973
+
2974
+ This means:
2975
+ - Adding a new vector index in `schema.cypher` makes a new label searchable without code changes
2976
+ - The `memory-reindex` tool can backfill embeddings for newly indexed labels
2977
+ - Index renames are transparent — the server discovers the current index names at startup
2978
+
2979
+ The cache is cleared via `clearIndexCache` after schema changes (e.g., after `memory-reindex` detects new indexes).
2980
+
2981
+ ---
2982
+
2983
+ ## Inbound Message Gateway
2984
+
2985
+ Every inbound message — regardless of channel (web admin, web public, WhatsApp DM, WhatsApp group) — passes through a centralised screening and classification step before reaching the agent. One Haiku call per message produces:
2986
+
2987
+ - **Content screening** — CLEAN / SUSPICIOUS / DISCARD verdict plus a prompt injection flag. DISCARD verdicts on public channels return a polite refusal without invoking the agent. Admin messages receive advisory screening only — flagged in the log but never blocked or modified.
2988
+ - **Query rewriting** — retrieval-optimised reformulation of the message for memory-search (public channels only; admin text is unchanged).
2989
+ - **Intent classification** — question / instruction / complaint / greeting / follow-up.
2990
+ - **Language** — ISO 639-1 code.
2991
+ - **Complexity** — simple / complex.
2992
+
2993
+ Short messages (under 5 words) skip the Haiku call but still get local pattern matching against the shared prompt injection vocabulary — this prevents short injection payloads from bypassing screening.
2994
+
2995
+ On Haiku timeout, API error, or missing API key, the raw message passes through unmodified (graceful degradation). The gateway never blocks the user from reaching the agent due to its own failure.
2996
+
2997
+ Gateway results are injected into the agent's system prompt as structured metadata, giving the agent context about the message before it begins processing.
2998
+
2999
+ ### Diagnostics
3000
+
3001
+ Every gateway invocation logs to `server.log` with the `[inbound-gateway]` tag, including channel, verdict, intent, language, complexity, latency, and fallthrough status. Non-clean verdicts get an additional warning log.
3002
+
3003
+ To check recent screening activity:
3004
+ ```
3005
+ grep '[inbound-gateway]' server.log | tail -20
3006
+ ```
3007
+
3008
+ ---
3009
+
3010
+ ## Tool Eagerness — eager-load vs deferred
3011
+
3012
+ The Claude Code SDK marks every MCP tool as **deferred** by default. The model cannot invoke a deferred tool until it has first paid a `ToolSearch` round-trip to load the schema — one extra turn per unique schema. Built-in SDK tools (`Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `Agent`, `WebSearch`, `WebFetch`) stay eager. There is no count threshold; the gate is per-tool.
3013
+
3014
+ The SDK's per-tool override is `_meta["anthropic/alwaysLoad"]: true` on each MCP tool's `tools/list` entry. Two surfaces apply it:
3015
+
3016
+ 1. **In-process plugins.** Every admin-eager tool is registered via `eagerTool(server, name, description, inputSchema, handler)` from `platform/lib/mcp-eager/` instead of `server.tool(...)`. The helper calls `server.registerTool` with the `_meta` flag set.
3017
+ 2. **Upstream graph proxy.** The upstream Python `mcp-neo4j-cypher` server has no `_meta` channel, so `platform/lib/graph-mcp/src/index.ts` intercepts every `tools/list` response on the wire and injects `_meta["anthropic/alwaysLoad"]: true` into each tool entry. The `[graph-mcp] tools/list eager-flagged count=<N>` stderr line confirms the injection fired.
3018
+
3019
+ **Curation rule.** Every MCP tool the admin agent calls routinely should be eager — registered via `eagerTool` (or arriving through the graph-mcp interceptor). Whether a tool is eager is decided at its registration site in the plugin's MCP `index.ts` (`eagerTool` vs `server.tool`); there is no separate allow-list constant. Admin-skill / specialist / public-agent tools that stay on `server.tool()` pay the ToolSearch tax only when their caller invokes them. The admin tool surface (`toolSurface.admin`, the `adminAllowlist: true` set) is the intended eager set; a routinely-called admin tool left on `server.tool()` is a gap to fix at the registration site.
3020
+
3021
+ **Observability.** Spawn-time emit: `[tool-surface] session=<convId> permission_allowed=N eager_intent=E eager_set_size=T`. Turn-end emit: `[admin-agent] turn-end ... toolsearch=N toolsearch_unique=U`. A non-zero `toolsearch` on a fresh turn for an eager-intended tool means a plugin reverted to `server.tool()` — fix at the plugin's MCP registration site, not the allow-list.
3022
+
3023
+ ## Spawn-time MCP and subagent registration
3024
+
3025
+ Each `claude` PTY spawn registers every callable MCP server and every dispatchable subagent before the operator's first turn. **Platform MCP servers come from one channel — installed plugins — for admin and specialist spawns (Task 502).** Claude Code's plugin system serves every plugin MCP tool under the long prefix `mcp__plugin_<plugin>_<server>__<tool>` (for platform plugins `plugin == server == directory`), which is the canonical name the admin `--allowed-tools` argv and every specialist `tools:` frontmatter bind to. Admin spawns no longer write a per-spawn `.mcp.json` or pass `--mcp-config`; the per-account env (`ACCOUNT_ID`, `USER_ID`, `NEO4J_URI`, `NEO4J_PASSWORD`, `PLATFORM_ROOT`, `CLAUDE_CONFIG_DIR`) rides the PTY env block.
3026
+
3027
+ **Public agents are the one exception.** A public-facing web agent should boot only the handful of servers it may use, not every installed plugin, so public spawns retain the per-spawn `mcp-config.json` (`--mcp-config <path>`) that restricts the server set to plugins exposing at least one `publicAllowlist: true` tool (minus memory when `liveMemory: false`). Per-spawn descriptors keep the SHORT prefix `mcp__<plugin>__<tool>`, which is why the public allowlist (`toolSurface.public`) stays short while admin/all are long. The descriptor's `command` routes through `lib/mcp-spawn-tee/dist/index.js` so each child server's stderr lands in `${LOG_DIR}/mcp-<name>-stderr-<date>.log` even on synchronous module-load throws (Task 743 wrapper). `--strict-mcp-config` only ever guarded auto-discovery of a project `.mcp.json`; it is retained on the public per-spawn path and dropped from admin spawns that no longer pass `--mcp-config`.
3028
+
3029
+ For subagents, the same spawn pushes `--add-dir` for every bundled plugin agents directory (`platform/plugins/*/agents/`, `premium-plugins/*/agents/`) — both roles — plus the per-account specialists directory `<accountDir>/specialists/agents/` (admin only). Claude Code's `subagent_type` dispatch reads the agent file off disk via the added directories; without `--add-dir` the dispatcher returns "no matching agent."
3030
+
3031
+ A boot gate refuses to start the manager when any admin-allowlisted tool `mcp__<plugin>__*` lacks a registered server. The signal is `boot-failed reason=mcp-allowlist-without-server plugin=<p> tool=<t>` followed by `process.exit(1)`. The remediation is a one-line edit to the named `PLUGIN.md`: add the `mcp:` block. The complementary observability emit `mcp-config-allowlist-coverage admin-tools=A admin-registered=R` (where `A === R`) confirms the invariant per boot.
3032
+
3033
+ A second boot gate walks every specialist `.md` under `platform/templates/specialists/agents/`, every bundled `<plugin>/agents/` directory, and the per-account `<accountDir>/specialists/agents/` directory, parses each file's `tools:` frontmatter line (canonical long-prefix names since Task 502), and classifies every tool name as one of: CC-native (Read, Bash, …), a tool the loaded `PLUGIN.md` set actually serves (matched as the long canonical name in `toolSurface.all`), a third-party MCP bridge (a `mcp__plugin_*` name whose plugin segment is NOT a maxy platform plugin — Playwright etc., upstream-owned, passes unconditionally), `unknown-tool-in-plugin` (maxy plugin namespace served but tool name absent), `unknown-plugin-namespace` (namespace served by nothing), `brand-excluded-plugin` (namespace served by nothing on this brand, **but** the brand's `brand.json#plugins.excluded` list names it), or `malformed-name` (not CC-native and not `mcp__`-shaped). The first three pass. The next two refuse boot with one `boot-failed reason=specialist-tool-drift specialist=<name> tool=<t> drift=<class> path=<…>` line per defect, then `process.exit(1)`. A maxy-plugin `mcp__plugin_*` name is validated against `toolSurface.all`, so a typo or stale long-prefix tool name still refuses boot rather than passing as a bridge; the build-time `check-canonical-tool-names.mjs` gate catches the same drift in instruction files before publish. `brand-excluded-plugin` is a structural pass: it lands in a per-specialist strip-list, the manager continues to boot, and at spawn time `pty-spawner` removes those tool names from the `--agent <name>` spawn's `--allowed-tools` argv. The complementary observability emit `specialist-tool-strip specialist=<name> plugin=<p> tools=<csv> reason=brand-excluded` fires one line per stripped (specialist, plugin) pair so an operator who reads `server.log` sees the brand filter doing work without cross-referencing `brand.json` against the template. The startup-self-test line `startup-self-test specialist-tool-drift=ok inspected=<N> stripped-specialists=<M>` confirms the gate ran and how many specialists carry strip-lists.
3034
+
3035
+ This gate was Task 173. The `brand-excluded` branch closes the recurring crash-restart loop on brands that ship without a plugin the shared `personal-assistant.md` template references (e.g. `realagent-code` excludes `telegram` while the template hard-codes `mcp__telegram__*`). The brand-agnostic template stays a single file; the brand-aware filter expresses what the specialist *may* do on this install while the template expresses what it *can* do across brands. Tool typos and renamed plugins still refuse to boot — only namespaces explicitly named in `plugins.excluded` are demoted to strip-and-warn.
3036
+
3037
+ **Brand-foreign premium bundles (Task 343 / Task 344).** Task 344 closes the loop one layer up: the installer bundler at [`packages/create-maxy-code/scripts/bundle.js`](../../../../packages/create-maxy-code/scripts/bundle.js) now applies the same `brand.json#shipsPremiumBundles` gate at *payload assembly time*, so foreign bundles never reach disk on the device. The gate is shared with the test suite via [`scripts/premium-bundle-gate.mjs`](../../../../packages/create-maxy-code/scripts/premium-bundle-gate.mjs) and accepts only two shapes — `undefined` / missing → ships nothing; `string[]` → ships only the named bundles. The legacy boolean `true` form is **rejected**: bundle.js hard-fails with `FATAL: brand.shipsPremiumBundles must be a string[] (boolean 'true' no longer accepted; enumerate bundles in <brand.json>)`. An allowlist entry naming a bundle directory that is absent on disk is also FATAL — silent over-shipping is the failure mode this gate exists to prevent. Each build emits one `[bundler] premium-bundle-gate brand=<n> mode=<m> shipped=[…] skipped=[…]` line. The runtime gate `walkPremiumBundles` at [`plugin-manifest.ts`](../../../ui/app/lib/claude-agent/plugin-manifest.ts) keeps the same shape and stays as defence-in-depth — on a correctly bundled payload, it walks only allowlisted bundles because foreign ones are not present. The drift-gate's `agents-dir-skipped reason=brand-foreign-bundle` line therefore fires only when something has staged a foreign bundle out-of-band.
3038
+
3039
+ **Structured journald mirror for boot-failed (Task 343).** Every `boot-failed reason=specialist-tool-drift …` line is mirrored to journald via `systemd-cat -t maxy-csm -p err` with the fields `specialist=`, `tool=`, `drift_reason=`, `agent_path=` so `journalctl --user -u <brand>-claude-session-manager.service -t maxy-csm` can filter by any of them without grep on `server.log`. The stdout line stays unchanged so the existing diagnostic one-liners keep working. `systemd-cat` absence (e.g. macOS dev box) is swallowed — the stdout line is the primary surface; the structured emit is auxiliary.
3040
+
3041
+ **Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.
3042
+
3043
+ **Brand-process start counter (Task 173).** `platform/ui/server-init.cjs` increments a persistent counter at `/tmp/server-init-<accountId>-restart.count` on every fresh start and emits `[server-init] start count=<N> account=<accountId> counter-path=<…>` to `server.log`. /tmp clears on reboot, so a clean reboot starts the count fresh; any value `>1` between operator-observed reboots means the brand process (driven by its `Requires=<brand>-claude-session-manager.service` clause) is restarting. The diagnostic one-liner is `grep '\[server-init\] start' ~/.<brand>/logs/server.log | tail -5` — the trailing `count=` value is the loop depth without counting SIGTERMs.
3044
+
3045
+ **Programmatic spawn entry point.** Every admin PTY spawn that needs a first user prompt — UI click, turn-recorder hook, future automation — routes through the single wrapper at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts). The wrapper owns the per-spawn enrichment (owner profile, dormant/active plugins, specialist domains, tunnel URL) and the `senderId` resolution; it forwards a single `POST /spawn` to the session manager on `127.0.0.1`, with `initialMessage` inlined on that body. The manager appends `initialMessage` as the trailing positional argv to `claude`, so the CLI processes it as the session's first user turn at PTY startup — no separate `POST /<sessionId>/input` call, no bracketed-paste. (Task 153.) See `admin-session.md` "Spawn-with-initialMessage wrapper" for the body schema and caller list.
3046
+
3047
+ **Recorder auto-archive (lifecycle, not user-initiated).** The session manager's `attachRecorderAutoArchive` ([`platform/services/claude-session-manager/src/http-server.ts:178`](../../../services/claude-session-manager/src/http-server.ts)) wires every spawn whose `senderId === 'turn-recorder'` to a JSONL watcher: as soon as the recorder's JSONL contains `"stop_reason":"end_turn"`, the manager calls `stopSession`, the PTY exits, the PID file is removed, and `fs-watcher.ts:275-297` demotes the row to `state: 'archived'`. This is the lifecycle archive path — the row stays in place, the JSONL stays on disk, no directory move. It is structurally distinct from the user-initiated `POST /api/admin/claude-sessions/:id/archive` route, which actually `mv`s the JSONL between `<slugDir>` and `<slugDir>/archive/`; that path is the operator pruning their visible session list, not the recorder's per-turn cleanup.
3048
+
3049
+ ## Tool Call Audit Trail
3050
+
3051
+ Every tool invocation by the admin agent produces a durable `ToolCall` node in the knowledge graph, linked to the `Conversation` that triggered it. This covers all admin agent tool calls — the full history of what the agent did, when, and in what context.
3052
+
3053
+ Each ToolCall record contains:
3054
+
3055
+ | Field | Description |
3056
+ |-------|-------------|
3057
+ | toolName | The MCP tool that was invoked (e.g. `memory-search`, `workflow-execute`) |
3058
+ | pluginName | The plugin that owns the tool |
3059
+ | input | Truncated JSON of the tool's input arguments |
3060
+ | output | Truncated response text |
3061
+ | isError | Whether the tool call resulted in an error |
3062
+ | startedAt / completedAt | Timestamps for the invocation |
3063
+ | sessionId | Links back to the originating conversation |
3064
+
3065
+ Records persist indefinitely and are queryable by the admin agent. Ask Maxy "what tools ran in the last session?" or "show me all tool calls from today" to review the audit trail.
3066
+
3067
+ Workflow-dispatched tool calls are tracked separately via `StepResult` nodes (part of the workflow execution system) and are not duplicated as ToolCall nodes.
3068
+
3069
+ ### Diagnostics
3070
+
3071
+ Tool call persistence logs to `server.log` with the `[persist]` tag:
3072
+ ```
3073
+ grep '[persist] tool-call persisted' server.log | tail -10
3074
+ ```
3075
+
3076
+ Each log entry includes the tool name and a truncated conversation ID for correlation.
3077
+
3078
+ ## Process provenance — durable actions emit Tasks
3079
+
3080
+ Every durable action — cloudflare tunnel-login, brand publish, future deterministic flows — emits a `:Task {kind:"<flow>"}` node carrying the action's lifecycle and a `:PRODUCED` edge to every entity the action created. This makes the graph traversable from the originating Conversation to every entity created during it via `(c)<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e)` — answering "what did this turn produce" in one Cypher hop.
3081
+
3082
+ The doctrine is enforced at the storage primitive: writes to `:Person`, `:UserProfile`, `:AdminUser`, `:Organization`, `:LocalBusiness`, `:CloudflareTunnel`, or `:CloudflareHostname` MUST include at least one inbound `:PRODUCED` edge whose source is one of `:Task`, `:Conversation`, or `:Message`. Subtype labels like `:AdminConversation`, `:UserMessage`, `:AssistantMessage`, `:AdminMessage` qualify because the gate checks the full `labels()` array. Bootstrap writes (PIN-setup, schema migrations, lazy first-session UserProfile creation) are exempt via `createdBy.agent === 'system'`.
3083
+
3084
+ Two surfaces emit the lifecycle: agent-driven actions call `work-create`/`work-update`/`work-complete` over MCP (`work-create` accepts `kind`, the canonical `inputsProvided` call-shape record, `inputs` + `inputSchema` for the operator-meaningful form payload, and `raisedDuringConversationKey` to resolve the `RAISED_DURING` edge). Shell-driven actions wrap their script invocation in [platform/ui/app/lib/cloudflare-task-tracker.ts](../../../ui/app/lib/cloudflare-task-tracker.ts) (cloudflare is the first; installer / brand-publish / OAuth-login deferred). Both surfaces emit the same `[task] action-start|step|done` log lines so operators can grep one channel uniformly. Both also call the central `redactSecrets` primitive ([platform/lib/task-secrets/](../../../lib/task-secrets/)) to strip schema-tagged secret keys before persisting `inputs.<field>` props on the Task — see `.docs/neo4j.md § Audit Task input contract` for the contract that replaces per-kind allow-lists.
3085
+
3086
+ Two surfaces feed the gate. (1) **Workflow path:** `memory-write` accepts an optional `producedByTaskId` parameter. When set, an inbound `:PRODUCED` edge from that Task is composed into the write's relationships before the gate runs — the typical agent-side pattern is to call `work-create` at the start of an autonomous flow, capture `taskId`, and pass it as `producedByTaskId` on every subsequent `memory-write` for a gated label. The gate verifies Task and write share the same `accountId`; mismatch is rejected loud. (2) **Direct-ask path:** the admin server resolves the active `:AdminConversation`'s `sessionId` UUID and stamps it as `SESSION_NODE_ID` in the spawn env at PTY-spawn time. The same stamp propagates onto specialist subagent spawns the admin dispatches (Task 382) so listing-curator, content-producer, database-operator etc. inherit the same conversation anchor. The `contact-create` and `memory-write` wrappers call `injectConversationProvenance` (exported from [`@maxy/graph-write`](../../../lib/graph-write/src/conversation-provenance.ts)) which MATCHes `(c:Conversation {sessionId, accountId})` — account isolation is part of the natural key, not a separate gate — and prepends the synthetic `:PRODUCED` edge (composed by Neo4j elementId, which the helper reads off the MATCH). No agent-visible schema field changes. `memory-write` uses the env-stamp only as a fallback when `producedByTaskId` is unset; `contact-create` has no `producedByTaskId` parameter today and relies on the env-stamp alone. Autonomous (cron-driven) specialists with no parent conversation legitimately have no env-stamp; those must thread `producedByTaskId`.
3087
+
3088
+ Operator audit cyphers:
3089
+ - "What entities did this conversation's actions produce?" — `MATCH (c:AdminConversation {sessionId:$id})<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e) RETURN labels(e), e.name, t.kind, t.status`
3090
+ - "What cloudflare resources did this tunnel-login produce?" — `MATCH (t:Task {kind:'cloudflare-tunnel-login', status:'completed'})-[:PRODUCED]->(r) RETURN t.taskId, r.tunnelId, r.hostnameValue ORDER BY t.completedAt DESC`
3091
+
3092
+ See `.docs/neo4j.md § Process provenance doctrine` for the full enforcement contract, observability surface, and out-of-scope deferrals.
3093
+
3094
+ ## Context compaction
3095
+
3096
+ When an admin turn crosses 75% of the model's context window, Maxy runs a silent compaction turn that asks the agent to call the `session-compact` MCP tool with a structured briefing (what you asked for, what was done, decisions made, work-in-progress, things you've shared about yourself). The briefing is written to Neo4j; the next admin turn injects it back into the system prompt, so continuity survives across the compaction boundary without re-sending the full transcript.
3097
+
3098
+ The compaction runs against a transient one-shot pool entry separate from the long-lived admin Query. Operator-visible side effects:
3099
+ - Compaction logs land in `claude-agent-compaction-stream-YYYY-MM-DD.log` alongside the main stream log. Look for `[compaction-start]`, `[compaction-summary-captured]`, `[compaction-failed]`, `[compaction-timeout]`, `[compaction-crashed]`, or `[compaction-spawn-error]` to triage. Subprocess stderr is captured inline as `[subproc-stderr] <line>` — there is no longer a separate `claude-agent-compaction-stderr-…log` file.
3100
+ - The one-shot pool entry's lifecycle is greppable as `[client-cold-create] reason=compaction-one-shot …` paired with `[client-evict] reason=compaction-one-shot …`, distinguishable from the regular admin pool's lifecycle tags.
3101
+
3102
+ ---
3103
+ # Deployment Guide
3104
+ Source: https://docs.getmaxy.com/deployment.md
3105
+
3106
+ # Deployment Guide
3107
+
3108
+ ## Hardware Requirements
3109
+
3110
+ - Raspberry Pi 5 (16GB RAM minimum) with Raspberry Pi OS, **or**
3111
+ - Mac with macOS 14 (Sonoma) or newer — both Apple Silicon and Intel
3112
+ - 256GB storage minimum
3113
+ - Always-on power and network connection
3114
+
3115
+ ## macOS install
3116
+
3117
+ On macOS the installer uses Homebrew + launchd instead of apt + systemd. No flags are required on a laptop:
3118
+
3119
+ ```bash
3120
+ npx -y @rubytech/create-maxy-code # default brand
3121
+ npx -y @rubytech/create-realagent-code # realagent brand
3122
+ ```
3123
+
3124
+ **Prerequisite:** Homebrew. If `brew` is missing, the installer refuses with `Homebrew not found. Install from https://brew.sh and re-run.` Install Homebrew once via the official one-liner, then re-run. The installer never installs Homebrew itself.
3125
+
3126
+ **Hostname / printed URL.** Without `--hostname`, the installer reads `scutil --get LocalHostName` and prints the completion URL as `http://<that-name>.local:<port>`. No sudo, no system change — your Mac's existing local network name is what mDNS will resolve. With `--hostname <h>`, the installer sets `HostName` / `LocalHostName` / `ComputerName` to `<h>` via `sudo scutil` (one password prompt, all-or-nothing rollback within the three-call batch) so the URL becomes `http://<h>.local:<port>`. Grep `~/.<brand>/logs/install-*.log` for `[create-maxy] darwin-hostname-mode=` to confirm which path ran (`scutil-get`, `scutil-set`, or `brand-fallback`).
3127
+
3128
+ **LaunchAgent.** The installer registers `Maxy` as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.<brand-hostname>.plist` — for example `com.rubytech.maxy-code.plist`. Survives logout/login and reboot via `KeepAlive=true` and `RunAtLoad=true`. Two brands on the same Mac get two distinct plists (brand-hostname-keyed), so install order is independent. Use `launchctl print gui/$UID/com.rubytech.<brand-hostname>` for service state. The `[create-maxy] launchd-plist=<path> loaded=true` line in the install log confirms `launchctl bootstrap` accepted the plist; `loaded=false exit=<n>` is the failure signal (run `plutil -lint <path>` to diagnose).
3129
+
3130
+ **Cloudflare on darwin.** The installer brew-installs the `cloudflared` binary so it is on PATH, but does not invoke `cloudflared service install` or `cloudflared tunnel route dns` — public reach is opt-in. After install, the operator runs `cloudflared tunnel login` (browser-driven) followed by the existing tunnel-setup flow if they want a public address. Grep `[create-maxy] darwin-cloudflare-skip=true` in the install log to confirm the installer took the documented skip path.
3131
+
3132
+ **Uninstall.** `npx -y @rubytech/create-maxy-code uninstall` (or the realagent equivalent) bootsout the LaunchAgent, removes the plist, and deletes `~/.<brand>/`. Homebrew-installed dependencies (curl, git, unzip, jq, poppler, ffmpeg, node@22, neo4j, cloudflared) remain — remove them with `brew uninstall` if you want a clean slate.
3133
+
3134
+ **Pre-flight.** macOS < 14 is refused at pre-flight via `parseSwVers` (`sw_vers -productVersion` must be `≥ 14`).
3135
+
3136
+ **Diagnostic grep recipe.** After a Mac install, the canonical log path is `~/.<brand>/logs/install-*.log`. One pass tells you everything:
3137
+
3138
+ ```bash
3139
+ grep -E '^\[create-maxy\] (platform|darwin-hostname-mode|darwin-cloudflare-skip|launchd-plist|init-logging FAILED)=' \
3140
+ ~/.<brand>/logs/install-*.log
3141
+ ```
3142
+
3143
+ Every successful Mac install contains, in order: `platform=darwin`, `darwin-hostname-mode=…`, `darwin-cloudflare-skip=true`, every brew install/verify line, `launchd-plist=… loaded=true`. Absence of any of these is the failure signal.
3144
+
3145
+ ## Initial Setup
3146
+
3147
+ The Maxy installer handles the full setup. Run it on your Pi:
3148
+
3149
+ ```bash
3150
+ npx -y @rubytech/create-maxy
3151
+ ```
3152
+
3153
+ This installs all dependencies (Node.js, Neo4j, Cloudflare tunnel, Claude Code), configures the platform, and starts all services.
3154
+
3155
+ ## What the Installer Does
3156
+
3157
+ 1. Installs system dependencies
3158
+ 2. Installs Claude Code (the AI engine) and configures it
3159
+ 3. Installs and starts Neo4j (the memory database)
3160
+ 4. Installs and configures the Cloudflare tunnel for remote access
3161
+ 5. Creates your account and sets your PIN
3162
+ 6. Starts the Maxy web server on port 19200
3163
+ 7. Configures systemd so everything restarts automatically if the Pi reboots
3164
+
3165
+ ## First admin session — install-time defaults
3166
+
3167
+ There is no onboarding state machine. At install time the installer writes three defaults into `data/accounts/<accountId>/account.json` (`enabledPlugins` from the brand's default set, `outputStyle: "default"`, `thinkingView: "default"`) and stamps a minimal `agents/admin/SOUL.md`. Diagnostic lines on the Pi:
3168
+
3169
+ ```
3170
+ [install-defaults] account-json plugins=<n> outputStyle=default thinkingView=default
3171
+ [install-defaults] soul-md path=<path>
3172
+ ```
3173
+
3174
+ Grep for both in `~/.<brand>/logs/install-*.log`. Absence after a clean install is the failure signal.
3175
+
3176
+ The first user-domain write the agent attempts (e.g. recording who the operator is) hits the graph-write gate's `Write blocked (no-admin-user)` or `Write blocked (no-local-business)` error. The agent then asks the persona question, persists the answer through the `business-profile` skill or `profile-update.personFields`, and proceeds. The error itself is the signal — grep `Write blocked` in `~/.<brand>/logs/server.log` to confirm.
3177
+
3178
+ Cloudflare, WhatsApp, Telegram, and any other dormant capability surfaces on owner request via the `<dormant-plugins>` sentinel the manager injects per-spawn. Execution is the existing plugin skill (`cloudflare:setup-tunnel`, etc.) — no banner, no per-step flag.
3179
+
3180
+ ### Per-spawn system-prompt sentinels
3181
+
3182
+ Every PTY spawn injects an `--append-system-prompt` block composed of these sentinel sections in fixed order:
3183
+
3184
+ | Sentinel | Source | Behaviour on resolve failure |
3185
+ |---|---|---|
3186
+ | `<host>` | brand.json + boot-time LAN resolution | spawn refuses with `host-context-unresolved` |
3187
+ | `<attachment-ceiling>` | hard-coded 30 MiB rule | always present |
3188
+ | `<identity>` | `<accountDir>/agents/<role>/IDENTITY.md` | spawn refuses with `identity-unresolved` |
3189
+ | `<soul>` | `<accountDir>/agents/<role>/SOUL.md` | spawn refuses with `identity-unresolved` |
3190
+ | `<about-owner>` | upstream `loadUserProfile` + `formatProfileSummary` (UI process) | sentinel renders the prose body with `NOTHING (operator-data source unavailable: \`<reason>\`)` on line one; spawn proceeds |
3191
+ | `<specialist-domains>` | `<accountDir>/specialists/agents/*.md` frontmatter (UI process) | sentinel omitted when set is empty |
3192
+ | `<plugin-manifest>` | enabled plugins' PLUGIN.md frontmatter + skill SKILL.md frontmatter (UI process) | sentinel omitted when set is empty |
3193
+ | `<dormant-plugins>` | installed-minus-enabled set from `platform/plugins/` and `account.json`, excluding plugins whose PLUGIN.md frontmatter sets `surface: platform` (platform-shell — ships with every install, never opt-in). Absent `surface` field defaults to `feature` (dormant-eligible). | sentinel omitted when set is empty |
3194
+
3195
+ Diagnostic line per spawn (`~/.<brand>/logs/server.log`):
3196
+
3197
+ ```
3198
+ [pty-spawn] sessionId=<id> appendSystemPromptBytes=<n> identityBytes=<n> soulBytes=<n> aboutOwnerBytes=<n> dormantPluginsBytes=<n> pluginManifestBytes=<n> specialistDomainsBytes=<n> accountDir=<path> role=<admin|public> hostname=<h> lanIPv4=<ip> adminUrl=<url> tunnelUrl=<url|none>
3199
+ ```
3200
+
3201
+ The `pty-spawn-start` line additionally carries `hooksResolved=<event1,event2,…>` — the hook event names Claude Code would actually load for that PTY (resolved by walking the same `$CLAUDE_CONFIG_DIR/settings.json` plus the cwd-to-.git path Claude Code itself uses). A Stop hook registered in a settings file outside the loader's scope shows up as a missing event in this list.
3202
+
3203
+ Zero `aboutOwnerBytes` on any admin spawn is a regression: the upstream resolver dropped the field entirely. The prose body always carries the unconditional MAXIMISE imperative on line two, so the byte count is positive even on a fresh-account spawn whose line one is `NOTHING`. Zero `dormantPluginsBytes` on a Maxy install with `cloudflare` not enabled is likewise a regression. Zero `pluginManifestBytes` on any account with enabled plugins means the upstream walker failed silently.
3204
+
3205
+ The manager also runs a boot-time self-test that renders a fixture compose call against synthetic inputs and refuses to start if any sentinel is missing:
3206
+
3207
+ ```
3208
+ [claude-session-manager] startup-self-test system-prompt-sentinels=ok
3209
+ ```
3210
+
3211
+ LOUD-FAIL output is `startup-self-test system-prompt-sentinels=fail missing=<tag>` followed by an immediate process exit. Catches IDENTITY-promise-vs-emitter drift at boot rather than at the first real spawn.
3212
+
3213
+ ### Pre-publish boot smoke
3214
+
3215
+ `platform/scripts/smoke-boot-services.sh` runs inside `prepublishOnly` of the installer (`packages/create-maxy-code/`). For every service in its `SERVICES` list it builds a synthetic install dir that mirrors what `seed-neo4j.sh` writes on first boot — real templates copied from `platform/templates/agents/admin/*.md` and `platform/templates/specialists/agents/*.md` into both `<accountDir>/` and `<platformRoot>/templates/`, real plugin `PLUGIN.md` manifests, plus a `.claude/` dir for `CLAUDE_CONFIG_DIR`. The fixture stamps the same env shape the installer's systemd unit writes (dummy `NEO4J_URI`/`NEO4J_PASSWORD` — the manager only checks presence at boot; the live cypher gate runs separately). Then it spawns `node dist/index.js`, waits up to 10 s for the `startup-self-test identity-drift=` line (the last startup self-test the manager logs), SIGTERMs, and fails publish if any `boot-failed reason=` or `^\[.*\] fatal ` line appears. The script asserts each of the three startup self-tests (`specialist-tool-drift=ok`, `system-prompt-sentinels=ok`, `identity-drift=ok`) is present — the real-templates fixture is what makes the assertions non-trivial; Task 438 added it after the 0.1.143 / 0.1.147 / 0.1.155 / 0.1.156 install regressions slipped past an empty fixture. The original Task 099 motivation still holds (module-load regressions tsc and vitest miss — e.g. a stray `require()` in an ESM-typed package).
3216
+
3217
+ Cypher schema gate (Task 438): the same script applies `platform/neo4j/schema.cypher` to the maintainer's local Neo4j via `cypher-shell`, using `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` env (same shape `seed-neo4j.sh` reads — falls back to `platform/config/.neo4j-password` for the password). The dev's database is the test surface; schema commands use `IF NOT EXISTS` / `IF EXISTS` so re-apply is idempotent. Catches Neo4j 4 → 5 syntax drift (e.g. 0.1.151's `DROP FULLTEXT INDEX`) at publish time, not on a real install. Absence of `cypher-shell` on PATH or unset `NEO4J_URI` fails the gate loudly — the same toolchain the installer requires on every device.
3218
+
3219
+ Companion lint: `platform/scripts/check-no-esm-require.mjs` rejects `require(` calls in any `.ts/.tsx/.js` file inside a package with `"type": "module"`, also wired into `prepublishOnly`. Allowlist lives at the top of the script.
3220
+
3221
+ ## Service Management
3222
+
3223
+ Maxy runs via systemd and starts automatically on boot. You don't need to start it manually. To check if it's running, ask Maxy "Check system status."
3224
+
3225
+ If you need to restart the service manually (rare), ask Maxy to do it for you.
3226
+
3227
+ ## Browsing the brand filesystem on your LAN (SMB)
3228
+
3229
+ Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only binding posture.
3230
+
3231
+ ## Remote Access via Cloudflare
3232
+
3233
+ Maxy uses a Cloudflare tunnel to make your local Pi accessible from anywhere without opening router ports. The tunnel is configured during setup and runs as a background service.
3234
+
3235
+ Setting it up: say "Set up remote access." Maxy walks you through signing into Cloudflare, picking your domain (if you have more than one on your Cloudflare account), and then shows a form where you pick a short name that becomes your admin address. For example, entering `joel` gives you `https://joel.your-domain.com` for admin access. You can also pick a separate address for the public chat, or leave it blank to skip public access. The form only accepts valid address characters (lowercase letters, numbers, hyphens) — Maxy never asks you to type your full URL in chat.
3236
+
3237
+ Your admin URL looks like: `https://joel.maxy.chat` (the short name is whatever you picked in the form).
3238
+
3239
+ To check the tunnel status: ask Maxy "Check Cloudflare tunnel status."
3240
+
3241
+ To restart the tunnel: ask Maxy "Restart the Cloudflare tunnel."
3242
+
3243
+ ## Checking Service Status
3244
+
3245
+ Ask Maxy: "Check system status."
3246
+
3247
+ The `system-status` tool reports the health of all services: Neo4j, the web server, the Cloudflare tunnel, and all active MCP servers.
3248
+
3249
+ ## If Maxy Won't Start
3250
+
3251
+ From the Pi directly:
3252
+
3253
+ ```bash
3254
+ sudo systemctl status maxy
3255
+ sudo journalctl -u maxy -n 50
3256
+ ```
3257
+
3258
+ The logs will show which service failed to start and why. Common causes:
3259
+
3260
+ - **Neo4j not started** — run `sudo systemctl start neo4j` and retry
3261
+ - **Port 19200 already in use** — check for another process: `lsof -i:19200`
3262
+ - **Claude OAuth expired** — the next admin session will prompt you to re-authenticate
3263
+ - **NEO4J_URI guard throws** — the admin agent probes device reality at boot and fails closed on three shapes (earlier platform fixessucceeding earlier platform fixes):
3264
+ - `no Neo4j listening on [ports]` — nothing is bound; start `neo4j.service` or `neo4j-<brand>.service`, or edit `NEO4J_URI` to a port a Neo4j is actually running on.
3265
+ - `port:X not listening; only:Y is live` — single-brand device where `.env` names a port the local Neo4j isn't bound to; edit `NEO4J_URI` in `~/{configDir}/.env` to match the live port (shown in the `[neo4j-probe] listening=[…]` log line).
3266
+ - `port:X disagrees with brand.json neo4jPort:Y` — co-tenant device (2+ Neo4js listening) where `.env` names the other brand's port; edit `NEO4J_URI` to match `brand.neo4jPort`, or correct `neo4jPort` in `brand.json` and reinstall. Preserves the earlier platform fixes orphan-write protection on multi-brand devices.
3267
+
3268
+ ## Systemd units on each device
3269
+
3270
+ Each installed brand runs two per-brand `--user` systemd units (earlier platform fixes + — unit filenames are prefixed with the brand's `hostname` so two brands on the same device never share a unit file):
3271
+
3272
+ - `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. An earlier fix: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
3273
+ - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so the log stream survives the brand service's restart window. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.
3274
+
3275
+ Upgrade and Cloudflare setup run as detached actions: `systemd-run --user` transient units per invocation with stdout+stderr persisted to `~/.maxy/logs/actions/<actionId>.log` and streamed to the UI via SSE. No boot-time service file exists for these.
3276
+
3277
+ If an action looks stuck, read `~/.maxy/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record.
3278
+
3279
+ ## Linux laptops: snap-confined Chromium replacement
3280
+
3281
+ On Ubuntu 24.04 (Noble) the system Chromium binary at `/usr/bin/chromium` is a symlink into the snap. Snap's AppArmor profile denies writes to hidden directories under your home folder, so the per-brand Chromium profile at `~/.{brand}/chromium-profile/` is unwritable and the VNC browser never starts. Pi installs (Debian Bookworm) are unaffected because Bookworm ships a real `.deb` chromium.
3282
+
3283
+ The installer detects this case during system-dependency setup and replaces the snap binary with Google Chrome stable, installed from Google's signed apt repo. The chosen binary's absolute path is recorded in `<INSTALL_DIR>/platform/config/chromium-binary.path` and read by the two call sites that launch Chromium — the VNC service and the in-page Chromium wrapper. (The `browser` plugin never launches its own Chromium; it attaches over CDP to the VNC Chromium that `vnc.sh` starts with `--remote-debugging-port`, so it doesn't consult this path.) If you ever see `chromium-binary.path missing` or `Chromium ... resolves to ... which is snap-confined` in `~/.{brand}/logs/vnc-boot.log`, re-run the installer to re-provision.
3284
+
3285
+ The post-install acceptance gate at `platform/scripts/test-laptop-vnc-boot.sh` runs four checks: the configured Chromium realpath is non-snap, the path is absolute and executable, the per-brand CDP port returns Chromium version JSON, and the VNC boot log ends with `VNC + browser stack running` with no preceding `Chromium failed to start`. The gate runs automatically at the end of every install on Linux; manual invocation is `MAXY_PLATFORM_ROOT=<install-dir>/platform <install-dir>/platform/scripts/test-laptop-vnc-boot.sh`.
3286
+
3287
+ A separate operator-side harness at `platform/scripts/installer-device-verify.sh <published-version>` runs after every npm publish to confirm the installer reaches a terminal-success marker on each device in the operator's manifest. Two markers are accepted because the installer's CDP probe behaves differently per `DISPLAY_MODE`: `Browser automation ready (CDP connected)` on Pi (virtual display, persistent Chromium) and `[cdp-check] skipped reason=native-display` on laptop (native display, on-demand Chromium). Either is a pass. The harness is operator-only — end users do not run it.
3288
+
3289
+ ## Plugin registration at install time
3290
+
3291
+ The installer registers Claude Code plugins on the device as the last step before the brand service starts. After registration, `claude plugin list` on the Pi shows every Maxy platform plugin shipped by the brand, every premium sub-plugin shipped by the brand, and any external plugins the brand declares (e.g. Telegram, Discord, iMessage from `claude-plugins-official`). Spawned `claude` sessions inherit those plugins from `~/.claude/` — the session manager passes no `--mcp-config` argv.
3292
+
3293
+ **Where the manifests come from.** The Maxy plugin source tree uses `PLUGIN.md` (YAML frontmatter) for plugin metadata, not Claude Code's native `.claude-plugin/plugin.json`. At bundle time, `scripts/generate-plugin-manifests.mjs` walks the payload and synthesises a Claude-Code-native `plugin.json` per plugin plus a `marketplace.json` at each tree root. The generator runs in `packages/create-maxy-code/scripts/bundle.js` after platform + premium plugins are copied into the payload, so the deployed install directory carries:
3294
+
3295
+ - `<INSTALL_DIR>/platform/plugins/<name>/.claude-plugin/plugin.json` per platform plugin
3296
+ - `<INSTALL_DIR>/platform/plugins/.claude-plugin/marketplace.json` (marketplace `maxy-platform`)
3297
+ - `<INSTALL_DIR>/premium-plugins/real-agent/plugins/<sub>/.claude-plugin/plugin.json` per sub-plugin
3298
+ - `<INSTALL_DIR>/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json` (`maxy-premium-real-agent`)
3299
+ - `<INSTALL_DIR>/premium-plugins/{teaching,writer-craft}/.claude-plugin/plugin.json` for bundle-root plugins
3300
+ - `<INSTALL_DIR>/premium-plugins/.claude-plugin/marketplace.json` (`maxy-premium`)
3301
+
3302
+ Generator schema:
3303
+
3304
+ | Field | Source | Notes |
3305
+ |---|---|---|
3306
+ | `name` | directory name (or `PLUGIN.md#name`) | Used as `<name>` in `plugin install` |
3307
+ | `description` | `PLUGIN.md` frontmatter `description` | Falls back to "{name} plugin" if absent |
3308
+ | `version` | `"0.1.0"` | Single version across all generated manifests |
3309
+ | `author` | `{ "name": "Rubytech LLC" }` | Object form required by Claude Code's validator |
3310
+ | `mcpServers["<name>"]` | only when `mcp/dist/index.js` exists | `{ "type": "stdio", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/mcp/dist/index.js"] }` |
3311
+
3312
+ Skills, agents, hooks, and commands directories at the plugin root are auto-discovered by Claude Code — no explicit field needed.
3313
+
3314
+ **Install flow** (`registerLocalAndExternalPlugins()` in `packages/create-maxy-code/src/index.ts`):
3315
+
3316
+ 1. Discover every `.claude-plugin/marketplace.json` under the install directory.
3317
+ 2. For each one not already in `claude plugin marketplace list`, run `claude plugin marketplace add <dir>`. Pre-existing entries log `[plugin-marketplace] added <name> idempotent=true`.
3318
+ 3. Snapshot `claude plugin list` once.
3319
+ 4. Build the desired plugin set = (every local marketplace's plugin entries) + (`brand.json#externalPlugins`).
3320
+ 5. For each desired plugin not in the snapshot, run `claude plugin install <name>@<marketplace> --scope user`. Already-installed plugins log `idempotent=true`. Failures log `[plugin-install] ERROR <name>@<src> exit=<n> stderr=<short>` but do not abort the installer — one plugin failing must not block the rest.
3321
+ 6. For each external plugin with a `configureSecret` field whose env var is set, pipe `/<name>:configure <secret>` into a one-shot `claude --print` invocation. Missing env vars log `[plugin-configure] SKIP <name> reason=no-secret-in-env env-var=<NAME>` and continue — pairing remains a per-operator manual step.
3322
+
3323
+ **Brand declaration** — `brands/<brand>/brand.json#externalPlugins`:
3324
+
3325
+ ```jsonc
3326
+ "externalPlugins": [
3327
+ { "name": "telegram", "marketplace": "claude-plugins-official",
3328
+ "configureSecret": "TELEGRAM_BOT_TOKEN", "channelPlugin": true },
3329
+ { "name": "discord", "marketplace": "claude-plugins-official",
3330
+ "configureSecret": "DISCORD_BOT_TOKEN", "channelPlugin": true },
3331
+ { "name": "imessage", "marketplace": "claude-plugins-official", "channelPlugin": true }
3332
+ ]
3333
+ ```
3334
+
3335
+ `channelPlugin: true` signals the session manager to include the entry in the spawn-time `--channels plugin:<name>@<marketplace>` argv. The session manager's `/spawn` and `/resume` HTTP routes accept an optional `channels: string[]` body field that maps directly to those argv flags. When the field is absent or empty, the spawn argv is byte-identical to today's `['--verbose', '--remote-control']` shape.
3336
+
3337
+ **Diagnostic path** — `grep "\[plugin-install\]" ~/.<brand>/logs/install-*.log | tail -50`; compare row count against `cat brand.json | jq '.externalPlugins | length'` plus the on-disk plugin count under `<INSTALL_DIR>/platform/plugins/` and `<INSTALL_DIR>/premium-plugins/`.
3338
+
3339
+ **Premium MCP dependency install** — Premium-plugin MCP servers ship `dist/` + `package.json` in the bundle but not `node_modules` (npm pack strips them, same as `server/`). `buildPlatform()` discovers every `<INSTALL_DIR>/premium-plugins/<bundle>/plugins/<plugin>/mcp/package.json` and runs `npm install --omit=dev` there, wiping any prior `node_modules` first. The summary log line `[install] premium-mcp-install dirs=<n>` is emitted before the loop runs, so `dirs=0` is itself a regression signal when a brand ships premium plugins.
3340
+
3341
+ ## Running multiple brands on one device
3342
+
3343
+ A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.
3344
+
3345
+ - **Separate:** each brand has its own install folder (`~/maxy/`, `~/realagent/`), its own config folder (`~/.maxy/`, `~/.realagent/`), its own web port, its own Cloudflare tunnel state, its own edge systemd unit (`maxy-edge.service` vs `realagent-edge.service`), and by default its own Neo4j database (Maxy on bolt port 7687, Real Agent on 7688). Action runner units are transient and per-invocation, not per-brand, so no naming conflict is possible.
3346
+ - **Brand-isolated Neo4j:** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
3347
+ - **Peer-aware system-unit guard:** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the earlier platform fixes reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged. The dedicated unit exports `NEO4J_HOME=<per-brand-data-dir>` alongside `NEO4J_CONF`, so `server.directories.run`, `server.directories.plugins`, and `server.directories.import` resolve per-brand — no collision with `/var/lib/neo4j/run/neo4j.pid`. The conf sed-overrides, mkdir-p, chown, and unit-write are idempotent and re-run on every install, so a host whose prior install left a broken unit recovers on retry.
3348
+ - **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
3349
+
3350
+ To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation:
3351
+
3352
+ ```bash
3353
+ # Already running Maxy on port 20000. Install Real Agent on a different port:
3354
+ npx -y @rubytech/create-realagent --port 19500
3355
+ ```
3356
+
3357
+ Uninstalling one brand removes only that brand's state when the other brand is present: this brand's install folder, config folder, its own Neo4j data (if it runs a dedicated instance; shared data is left alone), its Cloudflare tunnel, and its systemd service. Shared binaries (Ollama, `cloudflared`), apt packages, and device-wide caches (`~/.claude`, `~/.ollama`) are left in place because the other brand is still using them. When no other brand is present, the uninstaller performs a full device decommission as before.
3358
+
3359
+ ## Version provenance
3360
+
3361
+ The version that the burger-menu badge displays is the same string the installer wrote to disk. Five links in the chain, each with its own log signal so a wrong badge can be traced to the broken hop in one grep:
3362
+
3363
+ 1. `packages/create-maxy-code/package.json` — `version` field. Bumped manually by the operator (`bin/publish-installers.sh:7`) before each publish. Single source of truth for every brand at a given release; the bundle script propagates one bump to every brand.
3364
+ 2. `packages/create-maxy-code/src/index.ts:3828-3830` — the installer reads its own `package.json` into the `PKG_VERSION` constant at start-up.
3365
+ 3. `packages/create-maxy-code/src/index.ts:2018` — the installer writes `PKG_VERSION` to `<configDir>/.${BRAND.hostname}-version` and logs `[install] version-marker written path=<absolute> version=<semver>`. Absence of that line in install.log means no marker was written for this run.
3366
+ 4. `platform/ui/server/routes/admin/version.ts` — the `/api/admin/version` route resolves `config/brand.json` per request, reads `config/.${brandHostname}-version`, and logs `[admin/version] outcome=<...> installed=<...> versionFile=<resolved-path> npmPackage=<resolved-name>`. On any brand.json defect (file missing, parse failure, or missing `hostname` / `npm.packageName` field) it emits one `[admin/version] brand-config-fallback reason=<file-missing|parse-failed|field-missing> field=<hostname|npm.packageName> using=<default>` per (reason, field) pair per process, then falls back to the defaults (`maxy` / `@rubytech/create-maxy`). No fallback line in the process log = brand.json resolved cleanly.
3367
+ 5. `platform/ui/app/components/header/HeaderMenu.tsx` — the menu renders `v${versionInfo.installed}` from the route's JSON response.
3368
+
3369
+ Diagnostic when the menu shows the wrong version:
3370
+
3371
+ ```bash
3372
+ # 1. Marker file actually on disk?
3373
+ ssh <device> 'cat ~/.<brand>-code/install/platform/config/.<brandHostname>-version'
3374
+
3375
+ # 2. What did the route resolve to?
3376
+ ssh <device> 'grep "\[admin/version\]" ~/.<brand>-code/logs/server.log | tail -5'
3377
+
3378
+ # 3. Any silent brand.json fallback?
3379
+ ssh <device> 'grep "brand-config-fallback" ~/.<brand>-code/logs/server.log'
3380
+ ```
3381
+
3382
+ Empty output from step 3 = brand.json resolved cleanly and the badge reflects the file from step 1.
3383
+
3384
+ ## Upgrading
3385
+
3386
+ To upgrade Maxy to the latest version, ask Maxy: "Upgrade Maxy." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
3387
+
3388
+ The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.
3389
+
3390
+ ### Automatic upgrade alert
3391
+
3392
+ Maxy checks for new releases on every admin session start — whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.
3393
+
3394
+ The upgrade runs inside a live terminal embedded in the Software Update window — you see each installation step stream as it happens, and any password prompts from `sudo` appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.
3395
+
3396
+ The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.
3397
+
3398
+ ---
3399
+ # Samba Share
3400
+ Source: https://docs.getmaxy.com/samba.md
3401
+
3402
+ # Samba Share
3403
+
3404
+ Every Maxy install provisions a per-brand SMB network share so you can read and write the brand's install folder from Finder, File Explorer, the Files app on iOS, or any SMB-capable client on Android or Linux. No client install required — every modern OS speaks SMB natively.
3405
+
3406
+ The share lives next to the rest of the brand. On a device that runs more than one brand, each brand gets its own stanza, its own credentials, and its own lifecycle. Tearing one brand down never touches another brand's share.
3407
+
3408
+ ## What gets provisioned
3409
+
3410
+ The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:
3411
+
3412
+ 1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
3413
+ 2. **conf** — writes `/etc/samba/smb.conf` with a LAN-only `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`.
3414
+ 3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
3415
+ 4. **units** — `systemctl enable --now smbd nmbd` so the share is reachable as soon as the install finishes.
3416
+
3417
+ macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.
3418
+
3419
+ ## Share path
3420
+
3421
+ | Client | Address |
3422
+ |---|---|
3423
+ | macOS Finder | `smb://<hostname>.local` then pick the `<brand>` share |
3424
+ | Windows Explorer | `\\<hostname>.local\<brand>` |
3425
+ | Linux (`mount.cifs`, Nautilus, KDE) | `//<hostname>.local/<brand>` |
3426
+ | iOS Files | `smb://<hostname>.local` |
3427
+ | Android (Solid Explorer, CX File Explorer) | Host `<hostname>.local`, share `<brand>` |
3428
+
3429
+ `<hostname>` is whatever the installer printed at the end of `npx @rubytech/create-<brand>-code install` — usually the brand name on a fresh Pi (`maxy-code.local`, `realagent-code.local`). `<brand>` is the same string — it is also the install folder name under the install owner's home.
3430
+
3431
+ If `<hostname>.local` does not resolve from your client (some networks do not route mDNS), fall back to the LAN IP: `smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows.
3432
+
3433
+ ## Credentials
3434
+
3435
+ - **Username** — the Unix user that owns the install on the device. On a Pi or Hetzner box this is `admin`; on a self-hosted Linux laptop it is whatever Linux user ran the installer (for example `neo`). The installer persists this value to `~/.<brand>/.install-owner` so every later read uses the same identity the installer wrote.
3436
+ - **Password** — your current Maxy PIN. The same PIN that unlocks the admin UI unlocks the SMB share.
3437
+
3438
+ Both halves are required. SMB never accepts a guest connection; `map to guest = bad user` is set in the global stanza.
3439
+
3440
+ ## PIN rotation
3441
+
3442
+ There is no separate "SMB password." When you set or rotate the PIN in the admin UI, the platform's `set-pin` route runs `sudo -n smbpasswd -a -s <install-owner>` inline with the new PIN, behind a `NOPASSWD` sudoers grant written at install time and scoped to that exact command.
3443
+
3444
+ So:
3445
+
3446
+ - On a fresh Pi or Hetzner box, the share is reachable as soon as you set the first PIN. Before that point the `smbpasswd` entry does not exist and the mount fails with a logon error — that is expected.
3447
+ - Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
3448
+ - If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.
3449
+
3450
+ ## LAN-only binding
3451
+
3452
+ The `[global]` section binds smbd to loopback plus one LAN interface:
3453
+
3454
+ ```
3455
+ interfaces = lo <lan>
3456
+ bind interfaces only = yes
3457
+ ```
3458
+
3459
+ `<lan>` is whichever non-loopback interface has an IPv4 address — `wlan0` preferred, then `eth0`, then the first other interface with an address. If the device has no LAN interface at all, the installer refuses to provision and exits — there is nothing safe to bind to.
3460
+
3461
+ This is the structural guarantee that SMB never leaves the LAN, even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB. **On a Hetzner box the share is therefore not reachable from the public internet** — operators reach it by `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445`, or by running the Hetzner box on a private network that the operator's machine also joins.
3462
+
3463
+ ## Peer-brand lifecycle
3464
+
3465
+ A device that hosts more than one brand carries one stanza per brand in `/etc/samba/smb.conf`. The provisioner is idempotent and peer-safe:
3466
+
3467
+ - **Install a second brand** — the new brand's stanza is appended next to the existing one. The shared `[global]` section is rewritten to keep the LAN-only directives current but is otherwise unchanged. Peer-brand stanzas are preserved byte-for-byte.
3468
+ - **Re-run the installer on an existing brand** — the brand's own stanza is replaced in place. Peer stanzas are not touched.
3469
+ - **Uninstall one brand** — only that brand's stanza is stripped. `smbd` is then `reload`ed so the brand share disappears from the running config without dropping connections to peer shares.
3470
+ - **Uninstall the last brand** — after the stanza is removed, the uninstaller checks `hasAnyBrandStanza()` and, if false, stops and disables `smbd`/`nmbd`, runs `smbpasswd -x <install-owner>` to drop the smbpasswd entry, and `apt-purge samba`. If any peer stanza remains, the units stay running and the package stays installed — the uninstaller logs `Leaving smbd/nmbd + samba package in place — other brand stanza remains`.
3471
+
3472
+ The brand-stanza name is the only identifier the uninstaller matches on, so two brands with different `BRAND.hostname` values cannot collide.
3473
+
3474
+ ## Troubleshooting
3475
+
3476
+ - **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
3477
+ - **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
3478
+ - **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd nmbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
3479
+ - **Hetzner share not reachable from outside the box.** This is by design — see "LAN-only binding" above. Use SSH port forwarding.
3480
+
3481
+ Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.
3482
+
3483
+ ---
3484
+ # Troubleshooting
3485
+ Source: https://docs.getmaxy.com/troubleshooting.md
3486
+
3487
+ # Troubleshooting
3488
+
3489
+ ## Stream-log file for a fresh session is absent or empty
3490
+
3491
+ **Symptom:** Operator opens a new admin session, sends one turn, sees the agent reply, then `logs-read sessionKey=<…>` returns `file-not-found` or zero bytes.
3492
+
3493
+ **Invariant:** For every new session, the stream-log file exists on disk iff at least one token byte has been emitted, and contains the token bytes from the moment the first token returns to the operator. The single-writer mandate (2026-05-14) mechanically enforces both halves of the contract: the single writer module at `platform/ui/app/lib/claude-agent/stream-log-writer.ts` opens the file lazily on `streamLog.writeToken` (the SDK first-byte site at [`stream-parser.ts:296`](../../../ui/app/lib/claude-agent/stream-parser.ts#L296)), and the build gate `platform/ui/scripts/check-stream-log-writer.mjs` rejects every external `appendFileSync`/`createWriteStream` against the `claude-agent-stream-*` pattern at CI time. The first-token invariant is bound by `platform/scripts/__tests__/first-token-creates-stream-log.test.sh`: one operator turn, one token, `claude-agent-stream-<sessionKey>.log` exists and contains the token bytes — pass iff file present and bytes present. The hourly adherence runner `platform/scripts/log-adherence-check.sh` extends the device-side check with a duplicate-basename diagnostic (`dup-basenames=N` in the `[log-tee] adherence-check` line); `dup>0` is a P0 page meaning the writer collapse regressed.
3494
+
3495
+ **Diagnose if it ever recurs:** run `bash platform/scripts/__tests__/first-token-creates-stream-log.test.sh` from the install. Pass = invariant holds; any other exit = the writer-side existence contract is broken and one `[log-tee] missing-on-resolve sessionKey=<8> surface=<…>` line on `server.log` is the operator-visible signal (P0). For the duplicate-file class specifically (the 2026-05-14 recurrence trigger), `bash platform/scripts/log-adherence-check.sh` returns non-zero whenever any sessionKey has more than one `claude-agent-stream-<sk>.log` across account dirs.
3496
+
3497
+ ## A JavaScript-rendered page comes back empty from WebFetch or `url-get`
3498
+
3499
+ **Symptom:** A page that needs JavaScript to show its content returns empty or a shell document from `WebFetch` (summary) or `url-get` (verbatim, server-rendered).
3500
+
3501
+ **Resolution:** Use the `browser` core plugin's `browser-render` tool. It renders the page in the device's per-brand Chromium over the Chrome DevTools Protocol (the same browser the VNC viewer shows) and returns the rendered HTML plus visible text. It attaches to the already-running Chromium on `127.0.0.1:${CDP_PORT}` — nothing is downloaded or installed mid-session.
3502
+
3503
+ **Diagnose if it ever recurs:** grep the per-conversation stream log for `[browser-render]`. `rendered=true domBytes=<n>` is the healthy signal. `rendered=false outcome=cdp-unreachable` means no Chromium is listening on the brand's CDP port — confirm with `curl 127.0.0.1:<cdpPort>/json/version`. Other outcomes (`navigate-failed`, `load-timeout`, `evaluate-failed`) name the failed CDP step.
3504
+
3505
+ ## First user-domain write rejected by `[graph-write-gate] reject reason=no-admin-user`
3506
+
3507
+ **Symptom:** Admin chat reports "couldn't save that — set up your business profile first" or `[graph-write-gate] reject reason=no-admin-user` appears in `server.log` on the operator's first non-bootstrap write (a website, service, opening hours, etc.). Reproduces on Minimal-onboarded installs from before the seed-stamping fix shipped.
3508
+
3509
+ **Diagnose:** Tail the gate reject and self-heal lines together:
3510
+
3511
+ ```
3512
+ grep -E "adminuser-self-heal|graph-write-gate.*reject" <server.log>
3513
+ ```
3514
+
3515
+ - `[adminuser-self-heal] healed=1 …` followed by no `[graph-write-gate] reject` lines on subsequent writes — heal fired, the gate is now passing. Operator can retry.
3516
+ - `[adminuser-self-heal] healed=0 …` + `[graph-write-gate] reject … subReason=admin-user-no-accountid` — heal couldn't reach the broken node. Most likely cause: the env-side `ACCOUNT_ID` doesn't match any `:AdminUser.userId`. Cross-check `users.json[0].userId` against `MATCH (au:AdminUser) RETURN au.userId, au.accountId` — if the userId mismatches, the post-Task-904 `[admin-invariant]` line in the same log will show `direction=users-without-account` and the repair is to align the stores per `.docs/agents.md` § "Three-store admin auth invariant", not to retry the heal.
3517
+ - `[graph-write-gate] reject … subReason=no-admin-user-node` — the graph has no `:AdminUser` at all. Re-run the seed (`platform/scripts/seed-neo4j.sh`) under the install's env vars; the boot self-heal won't help because there's nothing to heal.
3518
+
3519
+ The `subReason=admin-user-no-accountid` path should be impossible on any install whose admin server has booted at least once after the boot self-heal shipped — if it fires, the diagnostic recipe is the cross-check above, not "rerun the heal."
3520
+
3521
+ ## Fresh install opens to "Set your remote password" on the LAN URL
3522
+
3523
+ **Symptom:** On a brand-new device, the LAN URL printed by `create-maxy` (e.g. `http://maxy.local:19200`) opens to a remote-password setup page instead of admin onboarding. This was a Task-647-era regression and should not occur on any install built.
3524
+
3525
+ **Diagnose:** On the Pi, grep the UI server log for the gate's disambiguation fields:
3526
+
3527
+ ```
3528
+ tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
3529
+ ```
3530
+
3531
+ - `resolvedKind=lan` on a `login required` or `not configured` line means the classifier sees the request as local — if the browser is still on the remote-auth page, something cached the older page before the fix shipped (hard-refresh the tab).
3532
+ - `resolvedKind=external` means the request chain presents as remote (routable IP in the first `x-forwarded-for` hop). On a LAN-only browser this points to a proxy or VPN rewriting headers between the browser and the Pi.
3533
+ - `resolvedKind=unknown` is a defect — the classifier could not identify the TCP peer. Capture the log line and file it; do not work around it.
3534
+
3535
+ **Fix:** If all three fields confirm the LAN shape and the gate still refuses, upgrade the platform (`Software Update` from admin chat) to pick up the Task-679 classifier.
3536
+
3537
+ ---
3538
+
3539
+ ## Remote sign-in is rejected with "Remote access requires TLS"
3540
+
3541
+ **Symptom:** Posting the remote-auth password returns a plain-text `400 Remote access requires TLS` response instead of completing sign-in.
3542
+
3543
+ **What this means:** The login endpoint will only issue a session cookie when the request arrived over HTTPS (via the Cloudflare tunnel). Browsers silently drop `Set-Cookie: Secure` on plain-HTTP responses, so minting a cookie there would produce a dead-end redirect. An earlier fix replaced that silent failure with this loud one.
3544
+
3545
+ **Fix:** Reach the admin surface through the tunnel hostname (e.g. `https://admin.<your-domain>`), not an IP or plain-HTTP URL. If you need LAN access, use the LAN URL (`http://<hostname>.local:<port>`) — LAN never hits the remote-auth endpoint.
3546
+
3547
+ ---
3548
+
3549
+ ## Agent Not Responding
3550
+
3551
+ **Symptom:** You send a message and nothing comes back, or the response never arrives.
3552
+
3553
+ **Check:**
3554
+ 1. Ask Maxy: "Check system status" — the `system-status` tool will report whether all services are running
3555
+ 2. Check the platform logs: ask Maxy "Show me the recent logs"
3556
+ 3. If the admin agent itself won't start: restart the platform (see below)
3557
+
3558
+ **Common causes:**
3559
+ - Claude API connectivity issue — check your Claude OAuth connection is still valid
3560
+ - Platform process has stopped — restart it
3561
+ - Network issue if accessing remotely — check your Cloudflare tunnel is running
3562
+
3563
+ **If the chat shows a single `[agent-loop-stop] same error twice — aborting` line and stops:** Maxy hit the same structured tool failure twice in a row inside one turn (e.g. a permission gate refused the same write twice, or two `Read` calls hit the same missing file). The runtime aborted the turn after the second occurrence to save tokens instead of running until the SDK turn budget exhausted. The blocker text names the tool and the first line of the error. Resolve the underlying cause (re-run the named skill, fix the missing prerequisite, etc.) and tap "Continue" — the next turn truly resumes the prior SDK session via the synthetic-tool-result contract, so Maxy picks up where it aborted instead of cold-querying its own session list. To see the diagnostic, ask Maxy: "Show me the most recent stall-recovery log line." Greppable post-deploy invariants: `[agent-loop-stop] reason=identical-tool-failure tool=<name> errorSignature=<sha8> toolInputDigest=<sha8>` followed by `[stall-recovery] kind=agent_loop_stop … handoff=resume-first` and on the next turn `[stall-resume] consumed kind=agent_loop_stop toolUseId=<8> priorSessionId=<8>`. The fallback path (when the SDK session id was lost) emits `handoff=metadata-only` + `[recovery-handoff] generated/consumed reason=agent-loop-stop` and the chat button reads "Start over" instead of "Continue". A `[recovery-handoff] WARN missing-on-cold-create` line means the fallback briefing wasn't persisted — surface to support.
3564
+
3565
+ **If a background task goes silent and the chat shows "A background task went silent — K of M completed":** Maxy's subagent stopped emitting progress for over 2 minutes. Tap "Continue" — the next turn resumes the prior session and reads a synthetic tool_result describing what completed before the pause, so the agent re-plans without losing the work it had done. Most stalls are upstream API latency rather than the subagent's approach failing — the resume-first path treats both correctly. Greppable post-deploy invariants: `[stall-recovery] kind=subagent_stalled … completed=<K>/? handoff=resume-first` followed by `[stall-resume] consumed kind=subagent_stalled toolUseId=<8>` on the next turn. If the button reads "Start over" instead, the parent's pending tool_use_id was not captured — the fallback path took over; the prior conversation is preserved as a `<recovery-context>` block in the cold-started session.
3566
+
3567
+ **Agent searches the filesystem after uploading a zip.** If you uploaded a zip and the agent burns several turns running `find` / `Glob` instead of unzipping, that is the symptom of the recovery-retry attachment-context regression (now closed by the recovery context preservation contract in `.docs/agents.md`). Greppable confirmation is the `[context-overflow-recovery] retry … attachmentsCarried=<n>` line in the conversation stream log. If you see `[context-overflow-recovery] WARN attachment-context-lost`, the regression has returned — surface to support.
3568
+
3569
+ **Turn budget exhausted with a horizontal rule separating two assistant turns.** When Maxy reaches its turn budget and the doubled retry also runs out, the chat now shows a one-paragraph assistant message that opens with `error_max_turns turns=A→B` (initial budget → final budget) followed by the recovery copy: "I reached my turn budget of N before I could finish this request. Try sending a smaller or more focused request, or ask me to use higher effort." That message is persisted to the graph, so the next page-refresh still shows it. The thin horizontal rule labelled "Session restored after timeout." that appears above your following turn signals that the prior turn forced a cold SDK-session restart inside the same conversation (pool eviction) — the agent's response after the rule is from a fresh SDK session even though the conversation thread is unchanged. Greppable post-deploy invariants: `[context-overflow-recovery] exhausted cause=max-turns-interrupted` count equals `[admin-persist] writer=persistMessageExhaust outcome=ok` count for the same sessionId window, and one `[session-store] storeAgentSessionId` line marks the cold-restart that drove the on-screen rule.
3570
+
3571
+
3572
+ **A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-session stream log (`logs/claude-agent-stream-<sessionKey>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
3573
+ **Wrong Claude account answering on a multi-brand device.** On a host running both Maxy and Real Agent, each brand's admin agent reads its own `~/${brand.configDir}/.claude/.credentials.json`; there is no longer a shared `~/.claude/` thrashing them against one another. If a brand reports auth failures or appears to be operating against the wrong subscription, check three things:
3574
+ 1. `grep "\[claude-auth\] init" ~/.${brand}/logs/server.log | tail -1` — the resolved path must end with `~/.${brand}/.claude/.credentials.json`. If a `[claude-auth] WARN cross-brand-path-detected` line is present, the runtime is still pointing at `~/.claude/`; the brand main service did not pick up the `Environment=CLAUDE_CONFIG_DIR=` setting (re-run the brand installer to refresh the unit file).
3575
+ 2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
3576
+ 3. `grep "\[install\] claude-creds pickup" ~/.${brand}/logs/install-*.log` — fires once on the first post-Task-923 install of any brand and moves the legacy `~/.claude/.credentials.json` into that brand's path. Subsequent brands install with no credentials and require a fresh `claude /login` inside that brand's chat (which writes to the brand-scoped path because the systemd unit env is in scope).
3577
+
3578
+ ---
3579
+
3580
+ ## Memory Not Working
3581
+
3582
+ **Symptom:** Maxy doesn't remember things you've told it, or search returns nothing.
3583
+
3584
+ **Check:**
3585
+ 1. Ask Maxy: "Check the Neo4j connection"
3586
+ 2. Ask Maxy: "Search memory for [something you know was stored]"
3587
+
3588
+ **Common causes:**
3589
+ - Neo4j service stopped — restart the platform, which restarts Neo4j
3590
+ - Memory index is stale — ask Maxy: "Reindex memory"
3591
+
3592
+ ---
3593
+
3594
+ ## Telegram Bot Not Receiving Messages
3595
+
3596
+ **Symptom:** You send a message to the bot and nothing happens.
3597
+
3598
+ **Check:**
3599
+ 1. Confirm the bot token is correct: ask Maxy "What Telegram bot token is configured?"
3600
+ 2. Verify the bot is running: send `/start` to the bot in Telegram
3601
+ 3. Check the MCP server logs: ask Maxy "Show Telegram plugin logs"
3602
+
3603
+ **Common causes:**
3604
+ - Bot token changed (if you regenerated it in BotFather) — update it by telling Maxy "Update my Telegram bot token"
3605
+ - Webhook not connected — restart the platform
3606
+
3607
+ ---
3608
+
3609
+ ## Plugin Errors
3610
+
3611
+ **Symptom:** A tool fails with an error, or a plugin says it can't connect.
3612
+
3613
+ **Check:**
3614
+ 1. Ask Maxy: "Show me recent errors"
3615
+ 2. Ask Maxy: "Restart the [plugin name] plugin"
3616
+
3617
+ **Common causes:**
3618
+ - Missing environment variable (API key, token) — the error message will name it; ask Maxy to help configure it
3619
+ - MCP server crashed — restarting the platform restarts all MCP servers
3620
+
3621
+ ---
3622
+
3623
+ ## Cannot Mount the SMB Share
3624
+
3625
+ **Symptom:** Mounting `smb://<hostname>.local` (or `\\<hostname>.local\<brand>`) fails with a "logon failure" or the share does not appear in your network browser.
3626
+
3627
+ **Check:**
3628
+ 1. Confirm you have set a PIN in the admin UI at least once. On a fresh Pi or Hetzner box the `smbpasswd` entry does not exist until the first set-pin runs — mounts before that point always fail.
3629
+ 2. Use the install owner as the username (`admin` on a Pi or Hetzner box; the Linux user that ran the installer on a self-hosted laptop) and the current Maxy PIN as the password. The SMB password is not stored separately — it is the PIN.
3630
+ 3. If `<hostname>.local` does not resolve from your client, mount by LAN IP instead (`smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows).
3631
+ 4. Rotate the PIN in the admin UI. That re-triggers the `smbpasswd` sync on the device. If the resync log line reads `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing`, restore `~/.<brand>/.install-owner` from the installer log.
3632
+
3633
+ See [Samba Share](./samba.md) for the full credential model and per-OS mount syntax.
3634
+
3635
+ ---
3636
+
3637
+ ## Restarting the Platform
3638
+
3639
+ From the admin interface, ask Maxy: "Restart the platform."
3640
+
3641
+ If Maxy itself isn't responding (the page loads but the agent won't connect), try refreshing the browser. If the page itself won't load, the platform process may have stopped — power-cycle the Raspberry Pi by unplugging and reconnecting power, then wait a minute for services to restart automatically.
3642
+
3643
+ ---
3644
+
3645
+ ## Checking Logs
3646
+
3647
+ Ask Maxy: "Show me the logs" or "Show errors from the last hour."
3648
+
3649
+ For specific plugin logs: "Show Telegram logs" or "Show contacts plugin logs."
3650
+
3651
+ Maxy has access to all platform logs and can filter them for you.
3652
+
3653
+ ---
3654
+
3655
+ ## Cloudflare Tunnel Down (Remote Access Broken)
3656
+
3657
+ **Symptom:** You can reach Maxy on your local network but not via your public domain.
3658
+
3659
+ **Check:** Ask Maxy "Check the Cloudflare tunnel status."
3660
+
3661
+ **Fix:** Ask Maxy "Restart the Cloudflare tunnel."
3662
+
3663
+ If the tunnel won't reconnect, re-run the Cloudflare setup: ask Maxy "Reconnect Cloudflare."
3664
+
3665
+ If the initial Cloudflare login fails during setup, Maxy will fall back to asking you for a connection key. You can create one in the Cloudflare dashboard (Maxy will guide you through this in the browser).
3666
+
3667
+ **If you switched Cloudflare accounts or are stuck on the wrong one:** ask Maxy "Reset my Cloudflare login and start over." This is a clean reset — Maxy clears every stored credential, then opens a fresh browser sign-in. The next sign-in binds to whichever Cloudflare account you choose, with no risk of the previous account's stored credentials silently coming back.
3668
+
3669
+ ---
3670
+
3671
+ ## "Bad Gateway" or holding page during an upgrade
3672
+
3673
+ `maxy-edge.service` (always-on front door) classifies upstream errors and serves a brand-aware response. There are two distinct user-visible shapes; the right one depends on what failed.
3674
+
3675
+ **Branded holding page (brand logo + "Starting") for ~10 s during an upgrade — this is expected and self-healing.** The edge process binds the public port immediately, but `maxy.service` (the upstream UI) takes ~10 s after restart to apply the neo4j schema and mount its 11 routes. Any browser navigation that lands during that window gets a self-contained HTML holding page that polls `/api/health` and reloads automatically once the upstream binds. The page renders the brand logo (inlined as a base64 data URI at edge boot from `<install>/server/public/brand/<assets.logo>`) and the brand display/body fonts (loaded from fonts.googleapis.com) — both paths bypass the unavailable upstream so the page never makes a same-origin asset fetch. When `brand.logoContainsName` is true the logo replaces the productName text; otherwise the page falls back to "Maxy is starting". No operator action required. The diagnostic line in `~/.maxy/logs/edge.log` is `[edge] upstream http error path=… err=connect ECONNREFUSED 127.0.0.1:<UPSTREAM_PORT> err-class=econnrefused-coldstart upstream=…` and disappears as soon as upstream binds. Boot-time confirmation that the logo resolved: `[edge] brand=<name> holding-logo=inlined assets-dir=<path>` — `holding-logo=missing` means the logo file wasn't found at `assets-dir`, the page degrades to text-only.
3676
+
3677
+ **Branded plain-text 502 ("Bad Gateway (Maxy unavailable)") — real upstream failure, not cold-start.** Any error class other than `ECONNREFUSED` (timeouts, resets, host-unreachable) returns the existing 502 path. The diagnostic line carries `err-class=other`. Read the log with `tail -200 ~/.maxy/logs/edge.log | rg 'err-class=other'` and check `~/.maxy/logs/server.log` for upstream stack traces — the upstream itself is the source.
3678
+
3679
+ **Continuous `err-class=econnrefused-coldstart` for >30 s past the last `[edge] listening` line** indicates the upstream never binds — the upgrade or boot has stalled. Recover via `sudo systemctl --user status maxy.service` and check the action runner log per the next section. Permanent-failure UI escalation (turning the holding page into an error after N seconds) is intentionally deferred.
3680
+
3681
+ **The literal string `maxy-ui` should never appear in `edge.log` or in any user-visible 502 body**, regardless of brand. If it does, the edge is running stale code — re-bundle and re-publish.
3682
+
3683
+ **Verifying the holding page locally:** `curl -sS -H 'Accept: text/html' http://127.0.0.1:<EDGE_PORT>/` while `maxy.service` is stopped should return HTML containing the brand `productName`. The `Accept: text/html` header is required — non-html clients (default `curl`, `fetch`, XHR) get the branded plain-text 502 instead, so the holding page's own `/api/health` polls don't break themselves during cold-start.
3684
+
3685
+ ---
3686
+
3687
+
3688
+ ## Software update and Cloudflare setup
3689
+
3690
+ Both flows run on the native Claude Code PTY surface in admin chat (Task 287). The retired action-runner / terminal-modal troubleshooting sections that lived here have been removed because those surfaces no longer exist; failures now manifest as plain stderr from the agent-invoked Bash command, visible in chat.
3691
+
3692
+ - **Software update.** Re-run `npx -y @rubytech/create-<brand>@latest` from a shell; if the installer fails, its stdout is the diagnostic record. HeaderMenu turns sage when `installed === latest`.
3693
+ - **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `references/reset-guide.md` and `references/manual-setup.md`.
3694
+
3695
+ ## Orphan Account Directory Archived to `.trash/`
3696
+
3697
+ **What happened:** During upgrade, the installer detected multiple account directories under `~/maxy/data/accounts/` and identified one as live (its `admins` list matches the device's `users.json`). Non-matching siblings are archived — not deleted — under `~/maxy/data/accounts/.trash/<uuid>-<ISO8601-ts>/`.
3698
+
3699
+ **Installer signal:** Look for these lines in the installer log or admin terminal output:
3700
+
3701
+ ```
3702
+ ==> [seed] identity-match: kept=<uuid-short> via userId=<first-8>
3703
+ ==> [seed] swept orphan: <uuid-short> →.trash/<uuid-short>-<ts>
3704
+ ==> [seed] orphan sweep: moved N → ~/maxy/data/accounts/.trash/
3705
+ ```
3706
+
3707
+ **Rollback (if the wrong account was kept):** The archive is preserved verbatim. Stop the platform, move the desired directory back, restart:
3708
+
3709
+ ```bash
3710
+ sudo systemctl --user stop maxy-ui
3711
+ mv ~/maxy/data/accounts/<live-uuid> ~/maxy/data/accounts/.trash/<live-uuid>-$(date -u +%Y%m%dT%H%M%SZ)
3712
+ mv ~/maxy/data/accounts/.trash/<archived-uuid>-<ts> ~/maxy/data/accounts/<archived-uuid>
3713
+ sudo systemctl --user start maxy-ui
3714
+ ```
3715
+
3716
+ **`.trash/` retention:** Archived directories are kept indefinitely. The platform never auto-empties `.trash/`. When you're confident the archived orphans are truly obsolete, remove the directory manually: `rm -rf ~/maxy/data/accounts/.trash/<uuid>-<ts>/`.
3717
+
3718
+ **Installer aborted with "identity-match FAILED":** Multi-account installs where no sibling matches `users.json[0].userId` abort loud — the installer refuses to pick one and refuses to sweep. Resolution: inspect `account.json` in each candidate dir (listed in the abort output), identify the correct owner, move the other(s) aside manually, then re-run the installer.
3719
+
3720
+ **A chat turn looks broken — assistant bubble never rendered:** Open `claude-agent-stream-<sessionKey>.log` and grep for `[sse-client]`. The five phases (`connected`, `event_received`, `render_complete`, `error`, `close`) tell the story in order. Missing `connected` = the chat fetch never returned 200; missing `event_received` = the server emitted nothing or the client lost the stream before the first frame; missing `render_complete` = the reducer never committed the assistant bubble (persist_ack never arrived).
3721
+
3722
+ ## Admin DevTools console floods with `onboarding-banner-mount` or `sessions-poll` lines
3723
+
3724
+ **Regression symptom.** Open DevTools on the admin shell at `/` with `onboardingComplete=false`, leave the page idle for a minute, then scroll back through the console. Thousands of `[admin-ui] onboarding-banner-mount onboardingComplete=false` lines (one per AdminShell render, ~40/min driven by the 3s sessions poll) with no per-tick poll telemetry indicates the banner-mount log has regressed back into the render body.
3725
+
3726
+ **Steady-state invariants at `/`:**
3727
+
3728
+ - `grep -c '\[admin-ui\] onboarding-banner-mount' ~/.maxy/logs/admin-ui-console.log` equals page-load count plus onboarding-flip count, not the render count. Sustained climb at idle means the banner mount log regressed back into the render body (fix).
3729
+ - `grep -c '\[admin-ui\] sessions-poll' ~/.maxy/logs/admin-ui-console.log` over a 60-minute idle window equals zero. The hook no longer installs a `setInterval`; every `sessions-poll` line is operator-triggered (initial mount, refresh button, post-mutation refetch). One or more lines during operator idle means `setInterval` was reinstated.
3730
+ - `outcome=error` lines name a real fetch failure on an operator-triggered refetch, set the `error` field, and surface in the sidebar.
3731
+
3732
+ **Reconcile signal:**
3733
+
3734
+ - `grep -c '\[admin-ui\] sidebar-meta-pane-reconcile' ~/.maxy/logs/admin-ui-console.log` should equal the count of End / Resume / Purge clicks while the metadata pane was open. A `to=gone` line without a paired Close click means the pane's auto-close logic regressed.
3735
+
3736
+ **Why this matters.** The render-body log was misleading: it read as "the admin agent is checking onboarding state continuously", when in fact `onboardingComplete` had not changed at all. The fix moved the log into `useEffect(…, [])` then dropped the per-tick poll entirely, so a quiet console is now the steady state. With both fixes in place, console output is a faithful record of what the page actually did each operator click.