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

# Real Agent platform architecture (catalogue)

Invoked by the admin agent directly.

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

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

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

---
# Real Agent Documentation — full corpus

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

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

# Getting Started with Real Agent

## What Real Agent Is

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

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

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

## The Two Interfaces

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

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

## Install the Dashboard as an App

The admin dashboard and the chat dashboard can be installed as standalone apps, so they open in their own window with their own icon instead of a browser tab. Installing changes nothing about how Real Agent works — it is the same dashboard, just launched like any other app.

**On a phone or tablet:**

- **iPhone or iPad (Safari):** open the dashboard, tap the Share button, then **Add to Home Screen**. A Real Agent icon appears on your home screen; tapping it opens the dashboard full-screen.
- **Android (Chrome):** open the dashboard, then either tap the **Install** prompt Chrome offers, or open the browser menu and choose **Install app** / **Add to Home screen**.

**On a computer (Chrome or Edge):** open the dashboard, then click the **Install** icon at the right of the address bar (or open the browser menu and choose **Install**). The dashboard opens in its own window from then on.

The admin dashboard and the chat dashboard install as two separate apps, so you can keep both on your home screen or dock. Each one opens straight to its own screen.

## First Power-On (New Device)

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

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

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

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

## First Run

When you first open the admin interface:

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

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

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

## How to Use Real Agent

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

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

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

### Voice Notes

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

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

You can also drop, paste, or pick an audio file (`.opus`, `.ogg`, `.m4a`, `.mp4`, `.mp3`, `.wav`, `.webm`) into the chat composer — for example a voice note forwarded from WhatsApp. An `.mp4` video file works too: its audio track is extracted and transcribed. The file is transcribed the same way the in-browser recording is, and only the transcript reaches Real Agent; the audio itself is discarded after transcription. If you type a message and attach an audio file on the same send, both arrive — the transcript follows your typed text.

## What Real Agent Remembers

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

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

## Reaching Your Data When Real Agent Is Unavailable

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

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

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

## Getting Help

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

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

# How Real Agent Works

## The Short Version

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

## The Raspberry Pi

Real Agent is a server that lives on your local network. It's always on, always available, and accessible:

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

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

## The Two Agents

Real Agent runs two agents simultaneously:

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

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

## Plugins

Everything Real Agent can do is provided by a plugin. Each plugin is a self-contained package:

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

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

**MCP helper death observability.** `claude` spawns each plugin MCP helper as its own child, so the manager cannot see a helper die. Every spawn-capable plugin therefore wraps its `mcp:` command with `lib/mcp-spawn-tee`, a parent-side node wrapper that is the helper's real parent and observes its true exit code, signal, and lifetime (including SIGKILL / transport crashes). The wrapper tees the helper's stderr to a wrapper-only per-session file `~/.{brand}/logs/mcp-<server>-<sessionId>.log` — never mixed with the admin-server enumeration copy — and emits lifecycle lines keyed `session=<id8> server=<name>`: `[mcp-helper] op=spawn … pid= entry=`, `[mcp-helper] op=boot … head=<first stderr bytes>`, and `[mcp-helper] op=exit … code= signal= lifetimeMs= stderr-tail=` on every exit (including a clean one). Lines are written synchronously to the per-session file and best-effort mirrored to `server.log` via the loopback log-ingest route. Diagnose a dead helper by `grep '\[mcp-helper\]' ~/.{brand}/logs/server.log` filtered by `session=<id8>`, then read the per-session file for its last output. Both spawn paths are wrapped: the manager's per-spawn `.mcp.json` and the generated `.claude-plugin/plugin.json` manifests that channel sessions take helpers from — the manifest generator copies the tee into each `mcp-manifest: auto` plugin at `lib/mcp-spawn-tee/index.js`, because the plugin-install cache snapshot severs `${PLATFORM_ROOT}`-relative paths, and a missing tee dist at generation time is FATAL. Full contract and diagnostic path in [`.docs/mcp-helper-observability.md`](../../../.docs/mcp-helper-observability.md). The alive-but-wedged case (no process exit) is the deferred `[mcp-helper-census]` reconciler.

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

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

## Roles

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

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

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

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

## Memory

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

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

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

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

## The Web Interface

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

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

**Handing a visitor's question to a specialist.** When one public agent routes a visitor to another (an orchestrator pointing an enquiry at a topic specialist), it hands back a link of the form `/{agent-name}?q=<url-encoded question>`. The specialist opens with that question already asked as the visitor's first turn, so the visitor does not retype it. This works for open agents (the chat opens straight away) and for email-gated agents (the question is held in the browser and asked once the visitor follows their access link in the same browser). The question is asked exactly once; reloading the page does not re-ask it. To use it, instruct the orchestrator agent to append `?q=` with the visitor's question, url-encoded, to the specialist's link.

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

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

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

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

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

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

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

The row payload carries `url: string | null` — the `claude.ai/code/session_<suffix>` URL captured from the `/remote-control` banner. **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` — `--remote-control` not passed), or before url-capture has fired on a channel-facing spawn (~2 s after spawn), or on rows whose JSONL+sidecar pair was archived before the banner landed. When url-capture eventually fires, `pty-spawner` writes the URL to the sidecar via `updateSidecar`, calls `watcher.refreshSidecar(sessionId)` to refresh the row index, and the manager pushes a `row-updated` SSE frame carrying the fresh URL — the client's Open-in-new-tab arrow appears in step. The Sidebar gates the arrow on `row.live && row.url !== null` and opens `row.url` directly with no `/meta` round-trip; each click logs `[admin-ui] sidebar-open-in-new-tab outcome=<ok|blocked> sessionId=<8-char>` (`blocked` fires when a popup blocker swallows `window.open`).

**Manager state shape.** 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. 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`, not the legacy session-manager `/list` route, which has been removed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**WhatsApp inbound `/input` post-condition + sink-timestamp invariant.** An accepted `/input` returns `200` the moment the PTY write lands, but a `200` is not proof a turn submitted. The manager fires a bounded post-condition poll and logs `op=input-submitted … ackMs=…` (PTY went `busy` or a new JSONL user entry appeared) or `op=input-no-turn … ptyStatus=… lastJsonlEntryAgeMs=…` when the window elapses — so an accepted-but-unsubmitted input is visible in ≈8s, not only at the 300s turn-timeout. On timeout the bridge attributes the cause from the JSONL tail: `op=turn-timeout cause=no-turn` (no completed turn) vs `cause=relay-missed` (an assistant `end_turn` landed but the follower never relayed it); a per-sender `op=inbound-outcome relay-ok=N timeout-no-turn=N timeout-relay-missed=N` tally makes the failure rate one grep. **Sink-timestamp invariant:** every `server.log` line is stamped with a leading ISO timestamp at the stdout/stderr sink of the process that emits it — two sinks, one per process (UI: `server-init.cjs`; manager: `src/install-log-sink.ts`) — because both systemd units append to one `server.log` and `append:` bypasses journald. Never hand-inline a timestamp in a log call. Full lifeline, failure signatures, and diagnostic greps in [`.docs/whatsapp-inbound-lifeline.md`](../../../.docs/whatsapp-inbound-lifeline.md).

**PTY lifecycle contract.** 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. The tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).

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

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

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

## Software Update and Cloudflare Setup

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

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

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

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

## Session Slot Safeguards

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

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

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

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

## Tool Permissions

Every install seeds `permissions.allow:[]` plus `defaultMode:"bypassPermissions"` into both the brand-scoped settings file (`~/.{brand}/.claude/settings.json`) and every account-scoped one (`<install>/data/accounts/<id>/.claude/settings.json`). `bypassPermissions` alone stops Claude Code from sending tool calls to its remote auto-classifier (it skips all permission checks), which would otherwise surface a permission prompt in the chat that an unattended session never answers. The `allow` array stays empty and **must never contain `"*"`**: Claude Code ≥ 2.1.167 rejects `"*"` as an allow token and renders a blocking Settings Warning that, under `--remote-control`, stops every `/rc-spawn` from binding. What each subagent is allowed to use is still controlled by the `tools:` line in its agent file, not by a top-level allowlist. To verify after an install: `cat ~/.{brand}/.claude/settings.json | jq '.permissions'` (expect `allow: []`). To repair an install seeded before this change: `bash <install>/platform/scripts/backfill-bypass-permissions.sh ~/.{brand}`.

**`autoMode` trust block.** The same brand-scoped writer ([`permissions-seed.ts`](../../../packages/create-maxy-code/src/permissions-seed.ts), `AUTO_MODE_BLOCK`) also seeds the harness `autoMode` block. `bypassPermissions` skips the auto-mode classifier entirely, so the block is **belt-and-suspenders**: it only matters when a session is toggled into `auto` mode, where the classifier otherwise soft-denies the doctrine-sanctioned Cloudflare scoped-token mint ("permission/credential escalation") because the install's own Cloudflare infra is not in the classifier's default trusted environment. The block keeps `"$defaults"` first in `environment` and `allow` (so the built-in Data-Exfiltration / force-push / prod-deploy guards stay intact) and adds prose declaring `api.cloudflare.com` the install's own provider endpoint plus an allow for minting/persisting narrow per-scope tokens. It is seeded **only** into the brand/user settings (`~/.{brand}/.claude/settings.json`) — the classifier never reads `autoMode` from the shared project (account-scoped) settings, so `setup-account.sh` does not carry it. An auto-mode agent cannot seed this itself (the default `hard_deny` "Auto-Mode Bypass" blocks any agent editing `.claude/settings*.json`); installer seed is the only path. To verify after an install: `CLAUDE_CONFIG_DIR=~/.{brand}/.claude claude auto-mode config | jq '.environment, .allow'` (expect the Cloudflare entries, `$defaults` expanded) and `jq '.autoMode' ~/.{brand}/.claude/settings.json`.

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

# Plugins Guide

## What a Plugin Is

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

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

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

## Plugin Groups

### Core (always active)

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

| Plugin | What it does |
|--------|-------------|
| `admin` | Platform management — system status, account settings, logs, session control. Also hosts the cross-cutting `plainly` skill: every text-producing agent (admin, public, every specialist) applies a plain-English precision pass to prose returned to humans, as a prime-directive prerogative. Agent-to-machine payloads (image-generate prompts, memory-write arguments, cypher) pass through verbatim. This is a prompt-level skill contract, not a hook: each agent's IDENTITY loads `skill-load skillName=plainly` on its first text-producing turn and applies the pass thereafter. Hosts the `superpowers-sprint` skill: structured sprint workflow built on the `superpowers` and `code-review` upstream plugins, dispatched on "run a sprint" or any `.tasks/NNN-*.md` invocation. |
| `memory` | Graph memory — search, write, reindex, and ingest knowledge |
| `browser` | Headless browser rendering — `browser-render` runs a JavaScript-heavy page in the device's Chromium and returns its rendered DOM. The JS-rendering leg of retrieval: WebFetch (summary) / `url-get` (verbatim, server-rendered) / `browser-render` (JS-rendered). |
| `maxy-guide` | User guide and platform documentation (this plugin) |
| `cloudflare` | Cloudflare Tunnel — remote access via your custom domain |
| `scheduling` | Calendar and scheduling — events, appointments, recurring triggers. The current instant — now, today, today's weekday — is in the injected `<datetime>` block; read it there rather than computing it by hand. To interpret a *specific* datetime (a stored timestamp, a future meeting time, its day of week), pass its ISO to the `time-resolve` tool, which returns the operator's locale view; `time-resolve` requires an ISO input and cannot read the clock. |
| `email` | Agent email account — setup, read, send, reply, search, auto-respond |
| `tasks` | Task lifecycle — create, update, list, relate, complete |
| `workflows` | Persistent named workflows — reusable instruction sets |
| `contacts` | CRM contact management — create, lookup, update, list |
| `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. The hook also persists the exact injected `additionalContext` per turn to `$LOG_DIR/prompt-optimiser-directives/<session>/<epoch>-<pid>.txt` and records `len`, `sha256`, and the stored `file=` path in `$LOG_DIR/prompt-optimiser-directive.log`, so any past turn's full ~17 KB directive is recoverable and verifiable (the JSONL attachment row Claude Code keeps is truncated to ≤2.6 KB). `platform/scripts/prompt-optimiser-audit.sh` reconciles breadcrumbs against stored files per session (`injected=N stored=M`; `N!=M` is the leak signature, and any breadcrumb whose `file=` is missing is named). |
| `url-get` | Faithful page retrieval — fetches a server-rendered page, writes a verbatim markdown copy to an account-scoped reference file (no model in the path, so no copyright refusal), and returns the cleaned page text (capped) plus the file path. No summary and no subprocess: a caller that wants a summary invokes url-get from a delegated subagent. Use instead of WebFetch when a faithful copy is needed (e.g. ingesting your own published writing). |

### Real Agent Plugins (user-selectable)

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

| Plugin | What it does | Enhances |
|--------|-------------|----------|
| `business-assistant` | Customer enquiries, scheduling, quoting, invoicing, daily briefings | Personal assistant |
| `sweep` | Catch-up composition — on "catch me up" / "what did I miss" / "morning sync" / "sweep", reads every connected inbox, calendar, and contact source, plus the site captures published sites write to Cloudflare D1 (enquiry forms, e-signatures, and other form completions), dedupes against the graph, and proposes to-dos, calendar items, contacts, and captured enquiries for approval. Composes `work`, `scheduling`, `contacts`, and `memory`; adds no tools and never sends. | Personal assistant |
| `sales` | Buying signal detection, closing techniques, objection handling | Personal assistant |
| `deep-research` | Structured multi-source research — query decomposition, source evaluation, citations | Research assistant |
| `projects` | Structured project execution — phased sprints, investigations, reviews, retrospectives, plus method references for stakeholder mapping, risk pre-mortems, task prioritization, and meeting-to-tasks | Project manager |
| `telegram` | Telegram bot — BotFather setup, messaging, channels | Personal assistant |
| `whatsapp` | WhatsApp messaging, pairing, and conversation browsing | Personal assistant |
| `replicate` | Image generation — three models for photorealistic, design, and fast draft images | Content producer, Research assistant |
| `web-designer` | Design and build a single-page website for any business and put it live on Cloudflare — guided intake, brand extraction from a URL, a domain-neutral builder, social tiles, and deploy with D1 form capture, gated access, and e-signing. Composes `cloudflare` deploy and `business-assistant` e-signing; adds no specialist and writes to no graph. | Content producer |
| `linkedin-import` | Import a LinkedIn Basic Data Export — Profile and Connections today, more CSVs as references land | Database operator |
| `notion-import` | Import a Notion workspace export (markdown + CSV) — pages, databases, hierarchy, attachments, schema-bounded relations, `@person` mentions account-filtered | Librarian |
| `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). | Librarian |
| `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. | Librarian |
| `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. | Librarian |
| `memory/skills/conversation-archive` | Source-agnostic conversation transcript ingest. One skill for WhatsApp `_chat.txt`, Telegram, Signal, LinkedIn DMs, Zoom transcript, meeting minutes, iMessage, Slack, X DMs — `--source <enum>` selects the per-source normaliser. Single Bash entry — `bash platform/plugins/memory/bin/conversation-archive-ingest.sh <archive> --source <enum> --participant-person-ids <csv> --scope <admin\|public>` — runs normalise → operator-confirms owner + every distinct sender (owner derived from env via Cypher, no flag) → sessionize at the fixed 8h gap → emit one JSON line carrying prepared sessions (turn-attributed text + per-session cursor). The dispatched specialist iterates the sessions in-turn, produces a typed-section JSON chunking for each, and calls the `memory-ingest` MCP tool with `conversationIdentity` set (writes `:ConversationArchive`, source=<enum>) once per session — chunks + cursor advance commit atomically inside one Cypher transaction, so a kill mid-archive resumes from the next session on re-issue without re-classifying anything already written. Re-imports are delta-append. Auto-creating participants is forbidden — any sender outside the operator-confirmed closed set LOUD-FAILs with `parser-miss`. Distinct from the live `whatsapp` plugin (Baileys). | Database operator |
| `memory/skills/conversation-archive-enrich` | Phase 2 for any named `:ConversationArchive` — source-agnostic per-row insight derivation. Operator-triggered (never auto-fires on Phase 1 completion). Walks the parent's `:Section` chunks in pages via the read-only MCP tool `mcp__plugin_memory_memory__conversation-archive-list-chunks`; the dispatched specialist reads each chunk in-turn and emits claims under the four-kind contract (`mention`, `task`, `preference`, `observed-relationship`); the skill hands those claims to `mcp__plugin_memory_memory__conversation-archive-derive-insights` for per-kind cypher emission, then runs the per-row operator gate (`wire / skip / reject`). Idempotent on `(elementId(chunk), kind, contentHash)` — re-runs collapse identical claims. Confidence floor is a hedging-avoidance instruction the skill embeds in the specialist's per-chunk prompt, not a numeric post-filter; the LLM step runs in-turn from the dispatched specialist rather than as a server-side OAuth round-trip. | Database operator |

### Claude Official (marketplace)

Third-party plugins from the Claude marketplace:

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

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

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

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

Install verbatim:

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

### Premium Plugins

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

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

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

| Plugin | Type | What it does | Public agent |
|--------|------|-------------|-------------|
| `teaching` | Skills | Interactive tutoring, lesson planning, and study pack generation from your knowledge base | Yes — all 3 skills serve students and parents |
| `real-agent` | Bundle (13 sub-plugins) | UK estate agency skills — sales, listings, vendor management, buyer management, lead generation, coaching, business operations, teaching, Loop CRM (five value pillars: auto-respond, viewing lifecycle, pipeline mining, listings prospecting, maintenance & preferences), PropertyData market analytics (valuation, sold prices, £/sqft baselines, £/sqft growth, demand-rent, area risk, planning precedent, UPRN matching, property-type distribution), gov.uk EPC floor-area lookup, property brochures, social-share image cards, A4 market reports, and single-address preval packs (full UK address → 4-page A4 PDF covering valuation, area, and demand). 3 specialist roles (negotiator, valuer, compliance) | 4 sub-plugins (estate-sales, buyers, estate-coaching, estate-teaching) |
| `writer-craft` | Skills + Agent | Manuscript review and writing craft — story architecture, reader engagement, prose craft, editorial practice, and multi-level review | No — writing craft serves the author |
| `venture-studio` | Skills + Agent | Founding-a-business workflow — office-hours discovery, brand pack, zero-to-prototype validation, the full investor data room (business plan, prospectus, term sheet, deck blueprint, A4 print pipeline), and a `legal-essentials` skill that drafts an NDA and a UK-GDPR/PECR privacy policy. Carries citation-routed method references for market sizing (TAM/SAM/SOM), pricing, monetization, competitor analysis, battlecards, strategy frameworks (SWOT/PESTLE/Porter's/Ansoff), and a pre-fundraise strategy red-team. Pre-seeds a `Project` with one `Task` per artefact so nothing gets forgotten. | No — founder-facing only |
| `management-consulting` | Skills | 21 MBB-style strategy skills for client engagements — situation assessment, growth-barrier and assumption diagnosis, market mapping, competitive intel, segmentation, profit-pool analysis, strategic options, pricing, business cases, portfolio review, operating-model design, initiative prioritization, transformation roadmaps, war-gaming, risk registers, KPI design, value realization, stakeholder alignment, executive narrative, and decision memos. No MCP server. | No — consultant-facing only |

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

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

A bundle's sub-plugins carry the skills, so each must be registered as its own Claude Code plugin for its skills to load — the installer does this at install time for every bundle the brand ships. When a sub-plugin's skills are missing (a specialist dispatches but its skill answers *Unknown skill*), the manager names the gap at boot: grep `server.log` for `[skill-registration] op=drift` — each line gives the account, the sub-plugin, and which registration surface is missing. The fix is to re-run the installer for this brand; a clean boot instead logs `startup-self-test skill-registration=ok`.

The same split applies to a premium plugin's **tools**: its MCP server mounts only when the generated plugin manifest carries an `mcpServers` block (set by `mcp-manifest: auto` in the plugin's `PLUGIN.md`). When a tool-plugin is enabled but its server never mounts — its tools are listed but every call finds an empty toolbox — the manager names it at boot: grep `server.log` for `[tool-registration] op=drift`, which gives the account, the plugin, and the reason (`no-mcpServers-manifest`, `dist-missing`, or `not-installed`). A clean boot instead logs `startup-self-test tool-registration=ok`.

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

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

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

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

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

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

## Choosing Plugins

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

## Adding or Removing Plugins

Tell Real Agent:

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

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

## Viewing Your Plugins

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

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

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

These operator-authored plugins survive reinstall because the installer's wipe zone excludes `data/`. They are loaded directly from `data/accounts/{accountId}/plugins/*`: the spawn manifest and the admin skill resolvers (`skill-load`, `skill-find`, `skill-search`, `plugin-read`) walk that per-account root alongside the shipped and premium plugin roots, so no copy into the platform plugins tree is made or needed. `store-skill` confirms the written skill resolves through that same walk before reporting success. Operator plugins are always active for their own account; the admin agent sees every operator skill by default, and per-skill `publicEmbed: true|false` controls which skills surface to the public agent.

To edit an operator-authored skill later, ask Real Agent 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 no longer surfaces it, since skills load directly from that path.

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

## Brand Templating (for plugin and skill authors)

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

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

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

## Tool risk classes (for plugin authors)

Every tool a plugin declares in its `PLUGIN.md` `tools:` block carries three mandatory
fields. `publicAllowlist` and `adminAllowlist` say who may call the tool. `riskClass`
says what happens when they do, and takes one of four values:

- `read` changes no state anywhere. A remote lookup counts as `read`; reaching the
  network is not by itself consequential.
- `write_local` mutates account files or the graph on this device and transmits nothing.
- `exec` runs an operating-system command, or code supplied by the caller or the operator.
- `external` transmits to, or changes state on, a system outside this device, or delivers
  a message to a person.

Leaving the field out is a refusal-to-boot condition, not a silent default. The platform
refuses to start rather than treat an undeclared consequence as harmless, because a tool
that quietly reads as `read` escapes every check downstream of it.

Declaring the class does not yet gate anything. It gives any consumer one honest answer
to the question "is this tool consequential?" without maintaining a list of tool names,
which is what the checks in `platform/plugins/admin/hooks/` have had to do until now.

A publish-time gate flags declarations that contradict what the tool's own name says it
does, and a boot line reports the census across all four classes. The classification
rule, the exception mechanism, and the diagnostic commands are in
`.docs/tool-risk-classes.md`.

## MCP Plugin Observability (for plugin authors)

Every `console.error` line from a plugin's MCP server is captured to a per-server raw log. Capture is automatic and needs no import or call: the `mcp-spawn-tee` shim spawns every MCP server and mirrors its stderr to `mcp-<name>-<sessionId>.log` (or `mcp-<name>-nosession.log` for a spawn with no session). That file is keyed by session, which is the conversation, and that is where you grep. Prefix your diagnostics `[your-tool]` so a grep isolates one tool.

There is no per-conversation stream log that interleaves MCP stderr with agent events. An investigator who greps a conversation's stream log for `[mcp:<name>]` and finds nothing has looked in the wrong place, not found silence: the per-server raw file above is the record.

**Bash commands stream straight into the PTY.** Real Agent Code's admin and public chat run on the native Claude Code PTY. 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) print their stdout and stderr directly, and the PTY renders the output in chat verbatim.

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

- `logs-read { type: "mcp" }` → tails the most recent `mcp-<name>-*.log` (per-server, keyed by session).

## Failure-path observability contract

The raw sink is written with `appendFileSync`, which returns once the kernel has queued the write, so a diagnostic `console.error(…)` followed by an immediate `process.exit(…)` still lands. There is no async buffer to lose it. The same holds for a synchronous module-load throw: Node writes the stack to raw fd 2, which the shim is already teeing, before the process exits.

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

1. **Plugin-side sync-write discipline.** A plugin that calls `process.exit` during module load (rare; `graph-mcp` is the in-tree example, spawning a child at boot to proxy upstream stdio) writes its failure cause to `process.stderr` synchronously at every named exit path, so the cause reaches the raw sink before the process terminates. The write is wrapped in its own try/catch, so a closed stderr does not mask the primary failure. This is the discipline any plugin author who knows their failure paths should follow.

2. **Platform-side `mcp-spawn-tee` shim.** Every node-based core MCP server is spawned via the `lib/mcp-spawn-tee` shim rather than `node <entry>` directly: Claude Code runs `node <shim> <entry>`, and the shim imports the real entry into its own process via dynamic `import()` — one node runtime, not two, and the shim is not the entry's parent. It replaces `process.stderr.write` with a tee before importing, so every stderr chunk the entry writes lands in `${LOG_DIR}/mcp-${name}-${SESSION_ID}.log` (or `mcp-${name}-nosession.log` when `SESSION_ID` is unset) via `appendFileSync` and is still passed through to the real stderr (Claude Code's consumer is unchanged). It is the only writer of that raw sink: the `mcp-${name}-stderr-<date>.log` twin, which a since-removed tee also wrote, has been retired — every line used to land there twice. Synchronous `appendFileSync` survives `process.exit`, so the sink captures even (a) a module-load throw before any plugin logging runs, (b) `MODULE_NOT_FOUND` on the entry script itself, and (c) anything else a plugin author missed. The shim emits `[mcp-helper] op=spawn session=<id8> server=<name> pid=<n> entry=<path>` before it imports the entry, and installs its own `SIGTERM`/`SIGINT`/`SIGHUP` handlers — there is no child to forward a signal to. It drives the exit itself only when the imported entry installed no handler of its own; when the entry has one, the shim defers and the entry's handler decides the exit code. This is the layer that makes capture independent of plugin discipline. Playwright stays unwrapped because it spawns via `npx`, not `node`.

No `[mcp-init-error]` probe exists in live code; `claude-agent.ts` has not carried one for some time. The shim's own `[mcp-helper] op=boot` and `op=exit` lines are the record of a failed MCP boot.

**Signal inventory after a failed session:** `[init] FAILED MCP servers: <names>` (names), `[mcp-helper] op=spawn session=<id8> server=<name> pid=<n> entry=<path>` (proof the shim started, and which entry it loaded), `[mcp-helper] op=boot session=<id8> server=<name> head=<first stderr bytes>` (proof the entry reached its own stderr), `[mcp-helper] op=exit session=<id8> server=<name> pid=<n> code=<n> signal=<s> lifetimeMs=<n> stderr-tail=<…>` (proof the shim saw the exit), `[mcp-helper] op=error session=<id8> server=<name> reason=<…>` (no entry was given, or the entry failed to import), and optionally `[<plugin>] …` lines from plugin-side sync-writes. Their union gives the investigator independent sources for the same failure. `op=exit` fires from the shim's own `process.on("exit")`, so it covers a clean exit, a non-zero exit, a post-load uncaught throw, and a catchable signal — an `op=spawn` with neither a matching `op=exit` nor an `op=error` is an uncatchable `SIGKILL`, not a broken shim. (`op=error` is the entry failing to import; that path suppresses `op=exit`.)

**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).

---
# House-Admin Cross-Account Authority
Source: https://docs.getmaxy.com/cross-account-authority.md

# House-admin cross-account authority

The house account is the operator-owned account that runs the admin assistant. Sub-accounts are isolated: each one's tools read and write only that account's data, which is what keeps tenants separate.

By default the admin assistant is scoped the same way — it acts on the house account. Cross-account authority lets a house-scoped admin session act on a named sub-account by passing that account as a target, while every other session (public visitors, a session already scoped to a sub-account, background specialists) is refused. The refusal is fail-closed: a session that is not house-scoped and names a target is denied, never quietly redirected to its own account.

The operator wants this for two surfaces: the account graph and the account filesystem. Each is a separate adoption; a coverage ledger tracks them and a background check confirms neither silently regresses.

## Creating a sub-account can name a managing admin

A client sub-account may have a responsible house admin, but one is optional. `account_create` takes the `userId` of a current house admin as the managing admin — the admin agent asks the operator which house admin manages the client (the choices come from `admin-list`) — and records it as `managingAdminUserId` on the sub-account; a supplied `userId` that is not a live house admin is refused. Omit it for a client run by its owner rather than a house team member. A WhatsApp requirement that comes in for that sub-account is filed as a task assigned to its managing admin; when the sub-account has no managing admin (or its recorded one is no longer a live house admin), the task is assigned to the install's primary admin (the first house admin) instead, so it always lands with a named owner rather than unassigned.

## Reassigning and removing a managing admin

To hand a client to a different team member — or to record a managing admin on an unmanaged account, or repair one whose admin was removed — use `account_reassign_admin` with the client's `accountId` and the new admin's `userId`. It refuses a target that is not a client sub-account or a `userId` that is not a current house admin. Removing a house admin is guarded so it cannot silently orphan anyone: `admin-remove` refuses to remove an admin who is still the managing admin of one or more client sub-accounts and names those accounts. Reassign each with `account_reassign_admin`, then remove the admin.

## Memory (graph)

The `memory` plugin gives the admin session access to a sub-account's knowledge graph. Its tools whose read or write is scoped solely by the account — search, lookup, write, update, edit, delete, edges, attachments, reports, conversation history, ingest, and graph maintenance — take an optional target. Omit it and the tool acts on the session's own account, exactly as before; pass a sub-account and a house-scoped admin session reads or writes that account's subgraph instead; any other session that names a target is refused.

`profile-read`/`update`/`delete` also take a target. They are keyed to a user as well as the account, so cross-account they cannot use the caller's own user (a house operator has no record inside a sub-account); instead they resolve the sub-account's owner — the single admin identity seeded with every account — and act on that owner's profile. A house-scoped session can therefore read or set a sub-account's profile (its owner's name, role, timezone, locale, and preferences); a target with no owner is refused rather than writing a stray record. Setting a sub-account owner's email or telephone this way is not available yet.

A few memory tools do **not** take a target. `session-compact` acts on the caller's own live session, which belongs to the account the operator is in, so pointing it at a sub-account has no meaning. `graph-prune-denylist-*` and `memory-ingest-extract` are not account-scoped at all (a shared deny-list and a temporary staging step), so a target would mean nothing. These stay on the session's own account.

## Filesystem

The `filesystem` plugin gives the admin session direct access to the account working directory (`data/accounts/<id>/`) — `SOUL.md`, generated documents, screenshots, arbitrary files. It exists because the native file tools (Read, Write, Edit, and the shell) are locked to the session's own account and cannot take a target. Its six tools are `file-read`, `file-write`, `file-edit`, `file-list`, `file-glob`, and `file-grep`.

Omit the target and each tool acts on the session's own account, exactly like the native tools. Pass a sub-account as the target and a house-scoped admin session reads or writes that account's files instead; any other session that names a target is refused. Every path is confined to the resolved account directory — a path that climbs out with `../`, an absolute path outside the account root, or a symlink pointing away is rejected before anything is read or written, so the tools can never reach another account or the platform's own files.

## Signals

A standing background check reconciles live sessions against the account roles and raises an alarm if any session still holds house authority for an account that is no longer the house account, so a stale grant cannot sit unnoticed. Each cross-account tool call also records its decision — who called, which account it targeted, whether it was allowed, and why — before it touches the graph or disk, and a rejected filesystem path escape is recorded on its own line. In normal operation neither an allowed cross-account call from a non-house session nor a path escape ever appears.

---
# Workflows Guide
Source: https://docs.getmaxy.com/workflows.md

# Workflows Guide

## What a Workflow Is

A workflow is a saved, named sequence of steps that Real Agent runs for you on demand. Each step calls one tool, and steps can pass their results to later steps. Use a workflow when you have a recurring process with two or more steps that you would otherwise ask for by hand every time. A single one-off action does not need a workflow.

Examples of good workflows:

- "When a new lead comes in, create a contact, log it, and send me a notification."
- "Every time I book a viewing, add the calendar event and message the client the details."

## Creating a Workflow

Describe the process to Real Agent naturally:

- "Set up a workflow: when I get a new lead, create the contact and send a Telegram notification."
- "Make a follow-up workflow that adds a calendar reminder and drafts an email."

Real Agent will confirm the name, the steps, and how data flows between them before saving. If a step needs a tool from a plugin you have not installed yet, the workflow is saved as a draft and activates automatically once that plugin is in place.

## Declared Inputs

Most workflows need a few values that are specific to each run, for example a customer name or a chosen option. A workflow can declare these up front as its inputs. Declaring them means Real Agent asks you the same questions every time it runs the workflow, so nothing is forgotten and nothing is guessed.

Each declared input has a question to ask you, a type, whether it is required, and an optional default. When a value has a default, Real Agent offers it and you can accept or change it.

You do not manage inputs by hand. When you describe a workflow that clearly needs a per-run value, Real Agent proposes the input and confirms it with you. At run time it reads the list and asks you each question before starting.

## Running a Workflow

Ask Real Agent to run it:

- "Run the new-lead workflow."
- "Start the viewing follow-up for the Cardinal Roofing booking."

Before it starts, Real Agent gathers every required input by asking you. If you leave a required value out, the workflow does not start and no steps run. Real Agent tells you which value is missing and asks for it, then runs the workflow with the complete set. This is deliberate: the workflow refuses to run half-finished rather than failing partway through.

## Seeing What Ran

Ask for the history:

- "Show me recent workflow runs."
- "Did the new-lead workflow run today?"

You get each run's status, timing, and how many steps completed, were skipped, or failed. If a step failed, Real Agent can show you which one and why.

## Managing Workflows

Tell Real Agent naturally:

- "List my workflows."
- "Show me the follow-up workflow."
- "Pause the new-lead workflow." or "Turn the new-lead workflow back on."
- "Change the follow-up workflow so it also messages me."
- "Delete the old onboarding workflow."

Pausing keeps a workflow but stops it running. Editing replaces the steps or the inputs with the new version you describe. Deleting removes the workflow and its run history, so Real Agent confirms with you first.

## Scheduling a Workflow

A workflow can run on a schedule instead of on demand. Tell Real Agent when you want it to run, for example "run the daily summary workflow every morning at eight," and it sets up the schedule. Scheduled runs happen with no one present, so a workflow that needs you to supply inputs is best run on demand rather than scheduled.

---
# Cash Book + AR/AP Ledger
Source: https://docs.getmaxy.com/ledger.md

# Ledger

A cash book with receivables and payables. It records what you invoiced, what you were billed, what was paid, and money that moved without an invoice, then tells you what is still outstanding.

## What it is not

It is not double-entry bookkeeping. There is no chart of accounts, no journals, no trial balance. It computes no VAT and handles a single currency. It does not read bank feeds and it does not talk to QuickBooks.

If you need statutory accounts, this is not that. It is the answer to "who owes me what, what do I owe, and where did the money go".

## The seven things you can ask for

**Record an invoice you raised.** A reference, who owes you, the amount, and when it is due.

**Record a bill you received.** The same, in the other direction.

**Record a payment.** Against either an invoice or a bill. You give the reference, the amount, the date and how it was paid.

**Record a credit.** Against an invoice you raised. It reduces what is owed exactly as a payment does.

**Record a cash movement.** Money in or out that belongs to no invoice: bank charges, a cash sale, drawings, a refund.

**Ask what is outstanding on one document.** You get the total, what has been paid, what has been credited, and what remains.

**Ask for a statement.** Everything owed to you and everything you owe, sorted into 0-30, 31-60, 61-90 and 90+ days overdue, plus net cash.

## You look the customer up first

When you record an invoice or a cash movement, the agent finds the company or person in your records first, then attaches the money to that exact record. It will not create a new company for you, and the name that appears on the invoice is taken from the record it attached to, so the two can never disagree.

That is on purpose. Deciding whether "Beagle Direct Ltd" is the same firm as an existing "Beagle Direct" is a judgement about your records, not a bookkeeping step, and getting it wrong quietly splits one customer's payment history across two entries.

## Three behaviours worth knowing about

**What is outstanding is never stored.** It is worked out from the payments each time you ask. This means the figure cannot fall out of step with the payments behind it. If it were stored, a payment recorded by any route that forgot to update it would leave the invoice quietly reporting a wrong number, and nothing would fail.

**Paying more than is owed is refused.** If an invoice has 4,400 left and you try to record 5,000, nothing is written and you are told the figure that would settle it. It is not rounded down to 4,400 and it is not recorded anyway. A ledger that quietly accepts an impossible payment is worse than one that says no.

**Every payment is read back.** After a payment is written, the invoice is re-read from the graph and the outstanding figure is checked against what was expected. If they disagree you are told to stop recording against that invoice until it is sorted out. Writing something is not the same as it having landed.

## The health check

Some ways this can go wrong announce themselves. Others do not: a payment that ends up attached to no invoice, a stale balance field reappearing, or the same payment recorded twice under two different ids. None of those produce an error at the time, because nothing goes wrong at the moment they happen.

So there is a check that runs by itself every fifteen minutes and writes one line to the log beginning `[ledger-census]`. When it is healthy the counters are all zero. When something is wrong the line ends with `alarm=true` and says which of them it found.

One of those counters is the number of writes that were refused in the last day. It is there because of a real failure: for a whole day every attempt to record money was refused, and the check still read as healthy, because a ledger nobody has used and a ledger that cannot be written to both show nothing.

You can also ask for that check on demand at any time, and it reports the same thing in plain words.

If you never see the line at all, that is itself the problem: it means the periodic check is not running.

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

# Installing Real Agent Code

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

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

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

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

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

# Installing Real Agent Code on macOS

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

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

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

## Requirements

- macOS 14 (Sonoma) or newer. The installer refuses to run on 13 and below; you will see `[create-maxy] platform=darwin macos=… — refusing: macOS 14+ required`.
- Apple Silicon (M1/M2/M3/M4) or Intel (x86_64). The installer resolves the Node binary and Homebrew prefix at install time and stamps a runtime `PATH` of `/opt/homebrew/bin:/usr/local/bin:$PATH`, so both the Apple Silicon cellar (`/opt/homebrew`) and the Intel cellar (`/usr/local`) work.
- Admin (sudo) account. The installer asks for your password once when it sets the system hostname via `scutil`; everything else runs unprivileged.
- A working internet connection — Homebrew, npm, and Cloudflare endpoints are all reached during install.

## 1. Install Node 22 via Homebrew

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

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

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

Node from the system PATH must resolve to Homebrew's `node@22` (`/opt/homebrew/opt/node@22/bin/node` on Apple Silicon, `/usr/local/opt/node@22/bin/node` on Intel). If `which node` points anywhere else, fix the PATH before continuing — the installer reads node from `PATH` and a 20.x binary will trip the engines check.

## 2. Run the installer

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

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

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

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

### Optional: `--hostname`

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

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

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

### Installing a second brand

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

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

The persist directory becomes `$HOME/.realagent-code`, the LaunchAgent becomes `com.rubytech.realagent-code`, and the admin URL switches to `http://realagent-code.local:<port>`. Because a non-default brand runs its own Neo4j on a dedicated bolt port (`realagent-code` → 7688, `sitedesk-code` → 7689; the default `maxy-code` keeps 7687), the installer provisions a second, isolated Neo4j supervised by its own LaunchAgent `com.rubytech.realagent-code-neo4j`, with data under `$HOME/.realagent-code/neo4j/`. That instance shares the Homebrew `neo4j` binary but reads a per-brand config, so the default brand's shared Neo4j on 7687 is left untouched. Every brand has its own isolated tree — there is no shared state, and `CLAUDE_CONFIG_DIR` is always `$HOME/.<brand>/.claude` for that brand, never the default `~/.claude`.

## 3. Confirm the LaunchAgent is up

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

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

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

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

Each brand runs two LaunchAgents: the main service (`com.rubytech.maxy-code`) and a session manager (`com.rubytech.maxy-code-claude-session-manager`). On a re-install `launchctl bootstrap` of the session-manager agent can return a transient `exit 5: Input/output error` even when the agent was not already loaded; the installer boots the agent out and retries automatically. Each attempt is logged as `[launchd] agent=<label> bootout=<rc> bootstrap=<rc> attempt=<n>`, and only an EIO that persists across every attempt aborts the install.

## 4. Open the admin UI

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

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

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

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

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

## 5. Verify reboot persistence

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

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

## Uninstall

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

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

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

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

## What this install does not do

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

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

## Smoke checklist

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

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

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

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

# Installing Real Agent Code on a Raspberry Pi

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

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

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

## Requirements

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

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

## 1. Prepare the OS

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

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

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

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

## 2. Run the installer

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

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

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

That command:

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

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

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

### Installing a second brand

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

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

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

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

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

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

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

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

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

## 4. Bootstrap the Cloudflare tunnel

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

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

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

## 5. Open the admin UI

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

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

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

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

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

## 6. Verify reboot persistence

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

If the admin UI does not respond after reboot:

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

## Uninstall

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

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

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

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

## What this install does not do

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

## Smoke checklist

Fresh-Pi smoke pass criteria:

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

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

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

# Installing Real Agent Code on a Hetzner Cloud server

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

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

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

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

## Server spec

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

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

## 1. Provision the server

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

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

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

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

## 2. Prepare the OS

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

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

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

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

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

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

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

## 3. Run the installer

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

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

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

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

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

### Installing a second brand

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

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

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

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

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

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

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

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

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

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

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

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

## 5. Bootstrap the Cloudflare tunnel

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

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

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

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

## 6. Open the admin UI

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

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

## 7. Verify reboot persistence

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

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

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

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

## Uninstall

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

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

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

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

## What this install does not do

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

## Smoke checklist

Fresh-Hetzner smoke pass criteria:

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

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

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

# Cloudflare Tunnel — the dashboard is the source of truth

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

Where one installation serves several client accounts from one Cloudflare account, that master is held by the house account alone, and a client account cannot mint from it — it is account-wide, so handing it to one client would hand them every other client's data. Publishing a client's website therefore runs house-side on the client's behalf: the client's session asks, the house does the deploy, and no Cloudflare token ever enters the client's account. Which website belongs to which client is recorded once by the house and is never inferred from folder names, so a site nobody has recorded cannot be published by anyone until it is.

## Identity model

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

Tunnel auth on the operator-owned path (Mode A) is the OAuth cert (`cert.pem`); API operations use a narrow token the agent mints from your master token. That master may be account-scoped (`cfat_…`) or user-scoped (`cfut_…`); the agent detects the type and routes API endpoints accordingly (the account vs user token endpoints) — see `plugins/cloudflare/references/api.md`. To switch Cloudflare accounts, the agent runs the reset flow from `plugins/cloudflare/references/reset-guide.md` (deletes the cert and every tunnel on the current account), then the manual-setup flow again — `cloudflared tunnel login` picks a fresh account when you sign in.

## Setup flow

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

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

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

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

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

## Getting a domain on Cloudflare

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

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

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

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

## Reset / account switch

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

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

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

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

## Manual runbook

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

## Dashboard operations the CLI cannot do

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

## Troubleshooting

### Tunnel won't start

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

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

### URL returns 530

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

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

### URL returns connection refused

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

### Admin hostname serves the public agent

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

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

### DNS not resolving

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

### Remote login issues

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

### Large `/data` upload fails with a connection error

- Cloudflare proxies a request body of at most ~100 MB on the current plan. A single upload larger than that is cut at the edge before it reaches the server, so the browser sees a connection error rather than a clean size rejection.
- The `/data` page handles this itself: files over 95 MB are split into ~48 MB parts and sent one at a time under the cap, and the server reassembles them. No plan change or dashboard action is needed for uploads up to the 2 GB app ceiling.
- Raising the proxied body limit is a paid-plan change made on the Cloudflare dashboard, not something the agent does.

## The client file portal

A portal gives named people their own passcode-protected page on a custom domain. Two things sit on it.

**What they send you.** Anything they upload lands in the account's own storage, one area per person, and nobody sees anyone else's files.

**What you have prepared for them.** The same page shows the account's deliverable folders — the work produced for that client. Which folders those are is not a setting to maintain: it follows the account's own filing structure, so a business whose work is organised into quotes sees quotes, and a business organised some other way sees that instead. Operator working data, the client's own uploads, and anything internal are never shown.

Folder names and sizes are published to the portal on a schedule, so the client can browse even while the device is off. The files themselves stay on the device. Opening one checks the device is reachable and then hands the browser a link that expires after a few minutes; if the device is down the client is told exactly that, rather than getting a broken download.

Each person is bound to one account when they are enrolled. Someone enrolled before this existed has no account bound and sees no folders until they are enrolled again.

## What the agent does and does not do

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

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

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

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

# Access Control

## What It Is

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

## Access Modes

Each public agent has one of two access modes:

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

## How to Set It Up

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

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

## Inviting Visitors

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

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

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

## What Visitors Experience

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

## Per-Visitor Memory

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

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

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

## Managing Access

All access management is done through conversation with Real Agent:

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

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

## Visitor Identity

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

## Action Approval

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

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

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

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

### Changing the Policy

Tell Real Agent to change which actions require approval:

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

Changes are per-account and take effect immediately.

## Filesystem Access (SMB Share)

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

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

# Settings

## Output Style

Controls how Real Agent communicates with you.

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

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

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

## Effort Level

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

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

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

Changes take effect on the next session.

## Thinking View

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

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

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

Changes take effect on the next session.

## Viewing Current Settings

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

## Default Agent

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

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

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

## Account Preferences

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

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

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

## PIN

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

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

## Adding admins

To add another admin to your account, tell Real Agent: "Add {name} as an admin with PIN {pin}." Real Agent 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, Real Agent returns an error naming exactly which store is dirty and what was already written; the admin record is partial and may need manual reconciliation. PINs are unique across all users on the device — a new admin needs a PIN no one else on the device is using.

If you ask Real Agent 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 Real Agent auto-generates a different 4-digit PIN, silently substituting what you asked for.

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

# Contacts Guide

## What a Contact Is

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

## Adding a Contact

Tell Real Agent naturally:

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

Real Agent will extract the details and confirm the record before saving.

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

## Looking Up a Contact

Ask naturally:

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

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

When Real Agent drafts an email to a contact, it reads that contact's record first. If the record carries routing you have set (for example an address to always copy, or a preferred send-to address), Real Agent applies it to the draft and shows it to you for approval before anything is sent.

## Updating a Contact

Tell Real Agent what changed:

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

## Listing Contacts

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

## Deleting a Contact

To remove a single contact from the graph:

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

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

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

## Exporting Contact Data (GDPR Subject Access)

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

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

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

## Erasing Contact Data (GDPR Right to Erasure)

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

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

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

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

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

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

## Stored Fields

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

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

# Memory Guide

## Brain-first lookup

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

## How Memory Works

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

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

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

## What Gets Remembered

Real Agent stores:

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

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

## Telling Real Agent to Remember Something

Just say it naturally:

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

Real Agent will confirm and store it.

## How Real Agent learns how you work

Real Agent 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 (Real Agent tracks around 28 in total) such as your preferred channel, quiet hours, workday start time, risk tolerance, content tonality, or address form. Real Agent 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 Real Agent 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 Real Agent 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 Real Agent needs to ask.

The standing rules you give it over a channel are held the same way, and two things keep them working. When you restate a rule in different words, Real Agent recognises it as the same rule and updates the one you already set instead of keeping two; a correction wins over the older version rather than sitting next to it. And on every channel message the rules that are currently in force are put back in front of Real Agent automatically, so a rule you set last week is applied this week without you repeating it, even after a long conversation. You do not have to re-teach a rule you have already given.

This one store is the single source for everything Real Agent holds standing about your account — the rules and preferences above, the conventions you work by, and the house-style assets it uses on your behalf. When a rule fixes which file to use — the signature file for an email, the template for a quote, the header for a document — that binding is held here too, naming the file to use; the file itself stays where it lives on disk, and the store is the one index that names it. So a correction is one change in one place, seen everywhere Real Agent works. It never keeps a second copy of the same rule in a separate notes file to drift out of step.

## Telling Real Agent to Forget Something

Be direct:

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

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

## Managing Documents

### Listing files

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

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

When you upload something for ingestion, Real Agent 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 Real Agent aborts loudly with a "Classifier unavailable — timeout" blocker and writes nothing — you can re-upload or split the document.

### Reading files

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

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

### Editing files

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

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

### Renaming files

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

Real Agent updates the filename in both the stored metadata and the knowledge graph.

### Deleting documents

Ask: "Delete the old pricing guide"

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

## Searching Memory

Ask naturally:

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

## Thinking tools

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

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

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

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

## Listing and counting

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

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

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

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

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

**Bulk cleanup of conversations in chat:** when you ask Real Agent 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, Real Agent reports "no candidates" and nothing happens.

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

**Typo-proof cypher.** When Real Agent 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 Real Agent 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 Real Agent surfaces the result count and a sample; if it ever describes a cypher rejection, that means its first attempt was malformed and it corrected itself.

## Bi-temporal timeline events

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

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

This distinction lets you ask two qualitatively different questions:

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

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

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

## Write doctrine

Every new node in Real Agent'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 Real Agent ever tries to record something without a link, the write is rejected and Real Agent asks you to clarify where it belongs.

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

**Two write surfaces, one substrate.** General agents write through schema-aware helpers — Real Agent 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 Real Agent dispatches when you ask for graph hygiene — "merge those two duplicate contacts," "wire those four tasks to the meeting," "rename the legacy label across the graph") additionally has a raw Cypher write tool for the multi-step operations the helpers cannot express. The steward role internalises the same connection-and-provenance discipline in its prompt; a post-write audit emits a warning on every breach so the same rules apply to both surfaces. Both paths feed the same hourly orphan trend and the same forensic provenance fields — read-side, you cannot tell the two apart, and that is the point.

## Vertical schemas

On top of the base graph, each brand boots one optional **vertical** — an extra set of entity types tailored to a trade. The vertical is named by `brand.json#vertical` and defined in a `schema-<name>.md` reference; the memory plugin loads it at startup and validates every write against base + the active vertical. Real Agent boots `schema-estate-agent` (Listing, Property, Viewing, Offer). SiteDesk boots `schema-construction`, the canonical JobLogic-derived field-service ontology, which adds the service-delivery entities — `Job`, `Customer`, `Site`, `Asset`, `Visit`, `Quote`, `QuoteLine`, `Valuation`, `VariationNote`, `JobCost`, `Part`, `PurchaseOrder`, `Invoice`, `InboundInvoice`, `PpmContract`, `WhatsAppGroup` (with `Engineer` and `Supplier` as additional labels on `Person`/`Organization`) — grounded in real builder job folders so a job's customer, site, quote, valuations, variations, costs, and received supplier invoices all hang off one `Job` node. The default Real Agent brand boots no vertical (base graph only).

## Public-facing summaries for customer-readable subjects

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

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

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

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

## Working across accounts

If you run Real Agent as the house account — the operator account that manages sub-accounts — you can point a memory action at one of your sub-accounts instead of your own. Name the account when you ask, for example "search Anneke's account for the roofing quote" or "add this note to the Jones account." Most memory actions honour this: search, read, write, edit, delete, reports, and conversation history. Profile actions honour it too — you can read and set a sub-account's profile (its owner's name, role, timezone, locale, and preferences) from the house account, for example "set the Jones account's name to Jane"; the profile is attributed to that sub-account's owner, so there is no orphan record and no PIN is needed. Setting a sub-account owner's email or telephone from the house account is not available yet. Compacting a conversation always acts on the session you are in, so it cannot be pointed at a sub-account.

Leave the account out and nothing changes — the action runs on your own account exactly as before. Only the house account can do this. A sub-account session that tries to reach another account is refused, and naming an account that does not exist is refused too. Sub-accounts stay fully isolated from each other.

## Privacy

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

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

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

# Projects Guide

## What a Project Is

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

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

## Creating a Project

Tell Real Agent naturally:

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

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

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

## Checking Project Status

Ask naturally:

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

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

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

## Updating a Project

Tell Real Agent when things change:

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

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

## Completing a Project

Tell Real Agent:

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

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

## Abandoning a Project

If a project is no longer needed:

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

Real Agent records the reason and marks the project as abandoned.

## Projects vs. Tasks

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

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

## Working a Task End to End

Ask Real Agent naturally:

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

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

Once you pick, the loop runs end to end:

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

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

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

---
# Sweep
Source: https://docs.getmaxy.com/sweep-guide.md

# Sweep Guide

## What a Sweep Is

A sweep catches you up. When you ask Real Agent to catch you up, it reads every surface you have connected — your inbox, your calendar, your contacts — compares them against what it already holds in memory, and shows you the gaps: action items with no to-do yet, calendar events it has not recorded, and people or facts it does not yet know. Nothing is written until you approve it.

A sweep proposes. It never writes on its own, and it never sends a message, an email, or an invite.

## Running a Sweep

Ask Real Agent naturally:

- "Catch me up"
- "What did I miss?"
- "Morning sync"
- "Sweep"

A sweep runs only when you ask. There is no automatic or scheduled sweep.

## What a Sweep Reads

Real Agent reads whichever surfaces you have connected:

- **Inbox** — recent mail, through whatever mail account is connected, looking for requests, commitments, and deadlines addressed to you.
- **Calendar** — upcoming events, looking for anything not yet on your Real Agent calendar.
- **Contacts** — people and details not yet in your contacts.
- **Site captures** — enquiries and form completions from your published website, such as contact forms and e-signatures. These do not arrive in your inbox; they land in the site's own database, and Real Agent reads the ones it has not brought in yet. Bookings made through a booking page are handled automatically and are not part of the sweep.

If only one surface is connected, Real Agent sweeps that one. If several are connected, it sweeps all of them. If a surface cannot be read, Real Agent skips it, tells you which one and why, and carries on with the rest, so a run never quietly leaves something out. If nothing is connected, Real Agent says so and stops.

## What a Sweep Proposes

After reading, Real Agent shows what is new, grouped by what it would create:

- **To-dos** for action items that have no task yet.
- **Calendar items** for events not yet recorded.
- **People and facts** for contacts and details it does not yet hold.

A site capture becomes one of these too: an enquiry becomes a new contact routed to whoever handles first contact, and a signature or completion becomes a note or a to-do. Each proposal says what it is, where it came from, and what it would create or change. You approve the ones you want. Real Agent then creates each approved record through the skill that owns it, and confirms once it is saved. Anything you decline is dropped.

## Running It Again

Run a sweep a second time and it proposes nothing it already recorded on the first run, because those records are now in memory and it recognises them. A repeat sweep is quiet on purpose; that is the sign your graph is already up to date.

## Automatic email→graph sweep (opt-in)

The sweep above runs only when you ask, and it proposes rather than writes. There is a second, opt-in routine that runs on a schedule and writes on its own: the automatic email→graph sweep, set up through the `email-graph-sweep` skill. On each run it reads the new inbound mail on your mailbox, pulls out the organisations, people, and live jobs it finds, checks each against what Real Agent already holds, saves the genuinely new ones, and then sends you a short report of what it read and saved.

Turning it on needs two things, and Real Agent tells you which is missing if either is absent:

- **A connected mailbox.** The routine reads your mail, so a mail account (a standard IMAP mailbox, or a Microsoft 365 mailbox) must be connected. With no mailbox connected there is nothing to sweep, and Real Agent says so instead of switching anything on.
- **A place to send the report.** The routine reports over a persistent channel, so you name the WhatsApp number or Telegram chat that receives it, and that destination must already be registered as an admin or account-manager. If it is not registered, Real Agent tells you that rather than falling back to a silent run.

Two things to know before you switch it on:

- **It messages you on every run.** It is a report you receive each time, not a silent background job. If you want a quiet, always-on tidy-up of the graph instead, that is a different routine.
- **It is one-account, deliberate opt-in.** Real Agent does not turn this on for you automatically, and it is held back from broad rollout for now while a safety fix for scheduled runs lands. Enable it for an account you are watching, not as a default.

Turn it off with the same skill: ask to stop the automatic email sweep and Real Agent cancels the schedule, so no further runs fire.

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

# Slides — user guide

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

## When to use it

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

## The commands

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

## Storytelling formats

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

## What you get

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

## Notes

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

---
# Connectors
Source: https://docs.getmaxy.com/connectors.md

# claude.ai connectors

claude.ai connectors (Zoom, Dropbox, GitHub, Google Calendar, and the rest you add on claude.ai) work
inside your admin chat once they are authorised for this Real Agent client. This page explains how the agent
can find a connector you have not added yet, why a connector you already added can still say it needs
authentication, and how to fix both without leaving the chat.

## Asking for a connector by name

You can ask the agent to use a connector you added on claude.ai by name — "can we connect my Zoom
account?", "save this to Dropbox", "check my Microsoft 365 calendar". The agent knows which connectors
are loaded for this client and whether each one is ready or still needs the one-off authentication
below, so it will reach for the connector rather than telling you it has no such integration. If a
connector you added is not yet authenticated for this client, the agent offers you the authentication
step straight away instead of dead-ending.

## Finding a connector you have not added yet

You can also ask for a connector you have never added — "do you work with Stripe?", "can you connect
Notion?", "is there a Linear integration?". claude.ai keeps a directory of managed connectors, hundreds
of them, and the agent can search it by name. So instead of telling you it has no such integration, the
agent looks the connector up and tells you whether it exists and how to turn it on.

1. The agent searches the directory for the connector you named.
2. If it is there, the agent names it and gives you a claude.ai link that enables that exact connector.
3. Open the link, enable the connector on claude.ai, and complete its sign-in.
4. Tell the agent you have enabled it. The connector flows through to your chat on its own — the agent
   confirms your chat picked it up and proves it with one live read, so you can see it actually works.

If the connector is not in the directory, the agent says so plainly and offers you the generic Real Agent
connector instead, which registers an ordinary REST API with credentials Real Agent holds. The agent never
invents a connector that the directory does not list.

## Why a connector can still need authentication

A claude.ai connector is authorised per client, not per account. Approving a connector in the
claude.ai web app authorises the web app only. Your Real Agent admin chat is a separate client, so the first
time it uses a connector that client has to be authorised too. This is a one-off step per connector.

That is why some connectors work straight away while a newly added one does not. A connector you used
before was already authorised for this client; a brand new one has only been approved in the web app.

## How to authenticate one from the chat

When the agent tries a connector that has not been authorised for this client, it will tell you the
connector needs authentication and hand you a link. You do not need a terminal and you do not need to
run anything yourself.

1. The agent gives you an authorisation link.
2. Open the link and approve the connector on claude.ai (sign in with the same account you use for
   your connectors).
3. Tell the agent you have approved it.
4. The agent reconnects your chat session. Send your next message and the connector is ready to use.

If a connector still reports that it needs authentication after you have approved it and the session
has reconnected, that is unexpected. Tell your installer so they can look into it.

## Who owns work that depends on a connector

Because a connector is authorised per client, and your admin chat is the client that holds it, only the
admin session can see a connector-backed surface. A specialist the admin spawns to do a subtask is a
separate, short-lived client with a fixed tool set that does not include your connectors, so it cannot
see them at all.

This matters most for your calendar. When a session holds connector tools, your real calendar often
lives on a connector (Google Calendar, Microsoft 365). The admin session sees it; a specialist does
not. So the admin session is the one that answers what is on your calendar, judges whether you are free,
and makes any booking that affects your availability, rather than handing that to a specialist that
would answer from the built-in calendar alone and miss the connector. A specialist still helps with
mechanical calendar chores the admin hands it with the facts already worked out, such as turning named
events into a calendar file. The same rule holds for any other connector: the admin session is the seat
that reasons over it.

A session that holds no connector tools cannot use a connector at all, and a connector is per client, so
a client sub-account session can never hold one. Such a session must not recommend connecting a
claude.ai connector for the calendar. Its calendar capability is the internal scheduling calendar
(`schedule-event`, `schedule-list`, and the rest) for live create and manage, plus a one-time
`schedule-archive-ics` import that pulls an existing Google, Outlook, or Apple calendar into the graph
from an `.ics` export. Full live per-account Google Calendar integration is planned;
until it ships, the internal calendar plus `.ics` import is the calendar path for a no-connector seat.

## What this is not

This is about Anthropic's claude.ai connectors, which authenticate per client. It is separate from the
Real Agent connector plugin, which registers ordinary REST APIs with credentials Real Agent holds. The two are
unrelated.

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

# Telegram Guide

## What the Telegram Plugin Does

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

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

## Setup

### Step 1: Create a Telegram bot

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

### Step 2: Connect the plugin

Tell Real Agent: "Set up Telegram" or "Configure the Telegram bot." Setup runs entirely in the admin chat — there is no settings page.

Real Agent asks for your bot token, then asks whether this is an **admin bot** (only your numeric Telegram IDs may message it) or a **public bot** (customer-facing, with a DM policy). It verifies the token, registers the bot's webhook on your brand's edge, stores the token under your brand's config (never in a shared system directory), and — for a public bot — sets the agent that will answer. The bot is now connected.

### Step 3: Start the bot

In Telegram, open your bot and send `/start`, then a message. Real Agent receives it and replies in the same chat. Admin-bot messages from your registered IDs reach the admin agent; public-bot messages reach the public agent you selected.

## Sending Messages via Real Agent

Once connected, tell Real Agent to send messages on your behalf:

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

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

## Getting a Chat ID

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

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

## Message History

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

## Troubleshooting

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

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

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

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

# Outlook Plugin — Operator Guide

The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com via Microsoft Graph. Mail is read (list, search, full-body), reply, delete, and compose (draft, draft-edit, send); contacts are read-only; the calendar is read plus control (create, update, cancel, respond, free/busy). Per-account OAuth (device-code flow), encrypted token storage, automatic refresh.

## Quickstart

1. **No app to register.** The client ID ships with the brand (`brand.json#outlookClientId`), and the installer stamps it onto the session-manager env, so there is zero operator Azure work. On a brand that ships an empty value the plugin stays inert until its vendor app exists — see `platform/plugins/outlook/references/auth.md` for the one-time-per-brand vendor provisioning runbook (a vendor step, not an operator or end-user step), including setting **Allow public client flows** to Yes.
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 returns a short user code and the URL `microsoft.com/devicelogin` and relays both to you.
3. **Consent on any device** — open `microsoft.com/devicelogin` on your phone or laptop, enter the code, sign in to your Microsoft account, and consent to the requested scopes (`offline_access`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`, `Contacts.Read`). `Mail.Send` enables sending and `Calendars.ReadWrite` enables calendar control; a mailbox last consented on an older read-only scope set must re-register before send or any calendar write succeeds. Nothing else to do: the server finishes the sign-in automatically once you consent, usually within about half a minute, and the mailbox is then connected.
4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.
5. **Several mailboxes on one account.** Register again to add a second mailbox; it is stored alongside the first, not in place of it. Every operational tool then takes an optional `mailbox` argument — an email address or graphUserId. With one mailbox, omit it. With two or more, name it; omitting it returns a refusal listing the connected mailboxes. `outlook-mailbox-list` shows every connected mailbox. Only one registration runs at a time per account; starting another while one is live returns an in-progress signal.

## Tools

| Tool | Purpose |
|------|---------|
| `outlook-account-register` | Start the device-code flow for this account. Returns a user code + `microsoft.com/devicelogin` URL immediately; does not block. |
| `outlook-account-register-poll` | Finish the device-code flow. One check per call: returns pending, registered, expired, or denied. |
| `outlook-mail-list` | Mail newest-first. Default top=25, folder=Inbox. `folder` takes a well-known name or alias (case/space-insensitive): Inbox, Sent Items/sent, Drafts, Deleted Items/trash, Junk Email/junk/spam, Archive, Outbox — or a raw folder id. Custom folder names are not resolved. `since`/`before` (ISO date-times) narrow by receipt date. The 250-per-page figure is not a ceiling: the reply carries `nextCursor` when older mail remains — pass it back as `cursor` to page further back. Filter by sender/subject via `outlook-mail-search`. |
| `outlook-mail-search` | Microsoft Graph `$search` (KQL) over the mailbox. Preview-only, like `outlook-mail-list`, and pages the same way via `nextCursor`/`cursor`. Put sender/subject/recipient/date filters in the query: `from:`, `to:`, `subject:`, `received>=`. Graph forbids `$search`+`$filter`, so a strict date window is on `outlook-mail-list`. |
| `outlook-mail-fetch-body` | Complete body of one message by id (`GET /me/messages/{id}?$select=body`). HTML decoded to text, no preview cap. The full-read path — list/search return Microsoft's ~255-char preview only. Returns the envelope plus the whole body. |
| `outlook-mail-attachment` | List or download any message's attachments — received or draft. With `messageId` alone, LISTS them (`id`, `name`, `contentType`, `size`, `isInline`, and a `kind` of file/item/reference) via `GET /me/messages/{id}/attachments` and `$select` (no bytes). A draft is a message in the same collection, so passing a draftId lists what that draft carries; the returned `name`/`id` is what `outlook-draft-edit`'s `removeAttachments` takes. With `messageId`+`attachmentId`, DOWNLOADS one file attachment: its bytes are written to `{accountDir}/uploads/outlook/<messageHash>/<bytesHash>-<name>` at `0o600` and the saved path is returned, ready for the Read tool, `SendUserFile`, or an `outlook-mail-reply` attachment. Only file attachments download — item/reference kinds, empty payloads, and anything over 25 MB are refused with nothing written. `outlook-mail-list` / `outlook-mail-search` / `outlook-mail-fetch-body` report `hasAttachments` so the agent knows when to call this. Needs `Mail.Read`. |
| `outlook-mail-reply` | Reply in-thread (`createReply` / `createReplyAll` + send). Threads natively via conversationId. `replyAll`, added cc/bcc, and account-scoped file attachments (up to 25 MB per file; files over Graph's ~3 MB inline limit use a chunked upload session). Body is plain text, prepended above the quote. Needs `Mail.ReadWrite` + `Mail.Send`. |
| `outlook-mail-delete` | Move messages to Deleted Items (`POST /me/messages/{id}/move`). Recoverable — never hard-deletes. An id already gone is counted not-moved. Needs `Mail.ReadWrite`. |
| `outlook-mail-ingest` | Write operator-reviewed messages into the business graph as `:ConversationArchive {source:'email'}` threads (the analogue of the IMAP `email-ingest`). Input `decisions`: one `{messageId, disposition:'ingest'\|'discard'}` per listed message. Each `ingest` message's full body + envelope is fetched, threads group by Graph `conversationId`, and participants resolve closed-set against Neo4j (`:Person`/`:AdminUser` by `accountId`); an unresolved From/To/Cc address skips its whole thread and is returned to the operator (no `:Person` auto-create). Resolvable threads dispatch through the shared `conversation-archive-ingest.sh --source email`. Human-in-the-loop: nothing lands without an explicit per-message decision. The plugin's only Neo4j coupling. Needs `Mail.Read`. |
| `outlook-mail-otp-extract` | Poll the inbox for a one-time code from a sender (domain). Reads full bodies to extract it; optional subject regex and timeout (default 60 s). |
| `outlook-calendar-list` | Calendar events in next rangeDays days (default 7, max 365). |
| `outlook-calendar-event` | Full detail of a single event by id. |
| `outlook-calendar-create` | Book an event. Needs subject, start, end; optional body, attendees, location, isOnlineMeeting, recurrence. |
| `outlook-calendar-update` | Reschedule or edit an event. Pass eventId plus the fields to change. |
| `outlook-calendar-cancel` | Cancel a meeting (notifies attendees when the account organises it) or delete a personal appointment. Mode chosen automatically. |
| `outlook-calendar-respond` | Accept, decline, or tentatively accept an invite. |
| `outlook-calendar-freebusy` | Availability for one or more addresses over a window. |
| `outlook-contacts-list` | Top contacts. Default top=50. |
| `outlook-mailbox-info` | Health probe for one mailbox — auth state, refresh-window, folder count. Name the mailbox with `mailbox` when several are attached. |
| `outlook-mailbox-list` | Every connected mailbox with its email, scopes, and token health. Local only — no network — so it also flags a mailbox whose stored credentials cannot be read. |
| `outlook-mail-send` | Send a message. No attachments → `POST /me/sendMail`; with attachments → draft, attach, send (`/me/sendMail` carries attachments inline and cannot fit a large file). to/cc/bcc arrays, subject, body, isHtml, attachments (account-dir paths, 25 MB/file). Success asserted on Graph 202; no message id on either route. Needs `Mail.Send` (+ `Mail.ReadWrite` to attach). |
| `outlook-draft` | Create a draft in Drafts, either a new message or a threaded reply. Returns the draft id; never sends. Attachments are attached before the id is returned, so the draft is ready to send. Needs `Mail.ReadWrite`. **New message** (no `replyToMessageId`): `POST /me/messages`, requires `to` + `subject`, same body model as `outlook-mail-send`, threads under nothing. **Threaded reply** (`replyToMessageId` = the id of the message being answered): `POST /me/messages/{id}/createReply` — the draft carries the parent's conversationId, so it lands under the original. This is the only way to draft a reply without sending it: `outlook-mail-reply` threads but always sends. Graph sets the recipients and `RE:` subject from the original and composes the body above its own quote, matching what `outlook-mail-reply` sends, so the threaded path is **plain text only** — `to`, `subject`, and `isHtml: true` are **refused** (not ignored) when replying. `replyAll` includes everyone on the original; `cc`/`bcc` add recipients. |
| `outlook-draft-edit` | Update an existing draft in place (`PATCH /me/messages/{draftId}`) and/or add and remove attachments. Graph drafts are mutable — pass draftId plus the fields to change (to/cc/bcc/subject/body/isHtml/attachments/removeAttachments). `attachments` adds; `removeAttachments` takes files off by name or attachment id; both together replace a file in one call. Sent messages cannot be edited. Needs `Mail.ReadWrite`. |
| `outlook-draft-send` | Send an existing draft by id (`POST /me/messages/{id}/send`). Graph consumes the draft, returns 202. Needs `Mail.Send`. |

## Drafting to a Contact

When the admin agent drafts or sends a message to a known contact, it reads that contact's record first and applies any routing the record carries, for example a preferred send-to address, showing the result for your approval before anything is sent.

## Observability

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

| Event | Line shape |
|-------|------------|
| Device code issued | `devicecode-issued account=<id> userCode=<code> verificationUri=<url> expiresSec=<N> intervalSec=<N>` |
| Poll (still pending) | `devicecode-poll account=<id> status=pending elapsedSec=<N>` |
| Device code expired | `devicecode-expired account=<id> elapsedSec=<N>` (terminal) |
| Consent denied | `devicecode-denied account=<id> elapsedSec=<N>` (terminal) |
| Pending-store census | `pending-store-census account=<id> live=<N>` |
| Pending cleared | `pending-cleared account=<id> verified=<bool>` |
| /me failed post-token | `me-failed account=<id> status=<N>` |
| Auth ok | `auth-ok account=<id> graphUserId=<id> mail=<addr> scopes=<csv> tokenExpSec=<N>` |
| Registration already in flight | `register-in-progress account=<id>` (start refused; one device-code flow at a time) |
| Credential resolve | `cred-resolve account=<id> mailbox=<id-or-empty> source=arg|sole|none|refused` — names the resolved mailbox and how it was chosen; `source=refused` is the ambiguity/not-found refusal |
| Token refreshed | `token-refreshed account=<id> oldExpSec=<N> newExpSec=<N>` |
| Refresh failed | `token-refresh-failed account=<id> reason=<err>` (terminal) |
| Mail list | `mail-list op=resolve account=<id> folderIn=<name> folderResolved=<token> asRawId=<bool> count=<N> elapsedMs=<N>` (on error, `error=<msg>` replaces `count`) |
| Mail search | `mail-search account=<id> query=<trunc-32> count=<N> elapsedMs=<N>` |
| Mail ingest (per message) | `mail-ingest op=ingest id=<id> conversationId=<id> disposition=ingest` |
| Mail ingest (per thread) | `mail-ingest op=thread-dispatched root=<key> messages=<N> participants=<N>` (or `op=thread-failed root=<key> status=<N> err=<msg>`; `status=null` with a non-`none` `err` is a spawn launch failure, not a script rejection) |
| Mail ingest (summary) | `mail-ingest op=summary ingested=<N> discarded=<N> threadsDispatched=<N> threadsSkipped=<N> unresolved=<N>` — one per call; `threadsSkipped` with `unresolved>0` is the closed-set resolution refusing an unknown participant, not a failure |
| Calendar list | `calendar-list account=<id> rangeDays=<N> count=<N> elapsedMs=<N>` |
| Calendar event | `calendar-event account=<id> eventId=<trunc-12> elapsedMs=<N>` |
| Event created | `cal-create-request account=<id> subj=<trunc-64>` then `event-created account=<id> eventId=<trunc-16> status=201`. A non-201 logs `event-create-failed ... status=<N>` and throws. A 201 whose body carried no readable id logs `event-created-id-unknown account=<id> status=201` and returns a created-but-id-unknown result (the event exists; do not retry) rather than a failure. |
| Event updated | `cal-update-request account=<id> eventId=<trunc-16>` then `event-updated ... status=200` |
| Event cancelled | `cal-cancel-request account=<id> eventId=<trunc-16> mode=<cancel-or-delete>` then `event-cancelled ... status=<N>` |
| Event responded | `cal-respond-request account=<id> eventId=<trunc-16> action=<a>` then `event-responded ... status=202` |
| Free/busy | `freebusy-request account=<id> requested=<N>` then `freebusy-result account=<id> requested=<N> resolved=<N>` (resolved counts error-free entries; resolved<requested flags a failed address) |
| Scope insufficient | `scope-insufficient tool=<t> account=<id> missing=Calendars.ReadWrite` (stale read-only consent) |
| Contacts list | `contacts-list account=<id> count=<N> elapsedMs=<N>` |
| Mailbox info | `mailbox-info account=<id> tokenWithinRefreshWindow=<bool> folderCount=<N>` |
| Mailbox list | `mailbox-list account=<id> count=<N> unreadable=<N>` (unreadable>0 flags an orphaned or corrupt mailbox store) |
| Graph error | `graph-error account=<id> status=<N> code=<graphErrorCode> retryAfterMs=<N-or-null>` — writes flow through the same `callGraph` loop, so a failed write logs `graph-error` too |
| On-prem rejected | `on-prem-rejected account=<id> mailServer=<host>` (terminal) |
| Scope insufficient | `scope-insufficient account=<id> tool=<t> missing=Mail.Send` (terminal — re-register to grant send) |
| Send request | `send-request account=<id> to=<N> subjLen=<N>` |
| Send args | `send-args account=<id> recipients=<N> hasBody=<bool>` |
| Sent | `sent account=<id> status=202 messageId=<id-or-null>` |
| Send failed | `send-failed account=<id> error=<msg>` |
| Draft request | `draft-request account=<id> threaded=<bool>` |
| Draft created | `draft-created account=<id> id=<draftId> threaded=<bool> convId=<id\|null>` |
| Draft failed | `draft-failed account=<id> threaded=<bool> error=<msg>` |
| Draft-send request | `draft-send-request account=<id> id=<draftId>` |
| Draft sent | `draft-sent account=<id> id=<draftId> status=202` |
| Draft-send failed | `draft-send-failed account=<id> id=<draftId> error=<msg>` |

## Diagnostic paths

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

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

# Threading audit — a threaded write that reports no conversation LOST its thread.
# Must always return zero. Covers both outbound threaded paths (mail-reply and draft-created).
grep -E "^\[outlook-mcp\] (mail-reply|draft-created)" ~/.maxy/logs/server.log \
  | grep -E "threaded=true|op=reply" | grep -E "convId=(null)?( |$)" | head
```

Threading triage: **absence of a `convId` on a threaded write means threading was lost.** `draft-created threaded=false convId=null` is correct and expected — a new message has no parent conversation. `draft-created threaded=true convId=null` is a fault: Graph returned a reply draft that names no conversation, and the reply will land detached from the original.

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

## Failure modes

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

## Out of scope

Mail move to arbitrary folders / flag, hard (permanent) delete, removing an attachment from a **sent** message (Graph forbids it), attachment-byte archival into the graph (`outlook-mail-ingest` lands text threads only; the IMAP `:DigitalDocument` archival is a separate concern), contacts write, recurring-series occurrence editing, the `scheduling` Neo4j calendar and any auto-sync with it, OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. Mail read, full-body read, inbound attachment list/download, reply, delete (to Deleted Items), and compose — all four outbound paths carrying attachments, with a draft's attachments editable in place (add, remove, replace) — are supported (`outlook-mail-fetch-body`, `outlook-mail-attachment`, `outlook-mail-reply`, `outlook-mail-delete`, `outlook-mail-ingest`, `outlook-mail-otp-extract`, `outlook-mail-send`, `outlook-draft`, `outlook-draft-edit`, `outlook-draft-send`), and the calendar is read plus control. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.

---
# Google Calendar
Source: https://docs.getmaxy.com/google-guide.md

# Google Calendar

Live Google Calendar for one account: read the user's real events, and create,
update, cancel, respond to and check availability against them. Twelve tools,
admin agent only.

There is deliberately no Gmail surface here. Mail runs on the `email` plugin's
IMAP/SMTP path, and Gmail's read/modify scopes are *restricted*, which would
force Google's annual third-party security assessment. Calendar is only a
*sensitive* scope, so it needs the lighter brand verification.

## Connecting an account

`google-account-register` resolves which of two routes applies to this install
and returns one `instruction` field plus a `readback` directive. Relay the
`instruction` to the user verbatim. Do not narrate both routes or decide between
them yourself; the tool already chose.

The two routes behind that resolution:

**Auto-opened** (`path: "auto-open"`), when the install's own browser is
reachable. Register opens the Google sign-in in it, so no link is handed over.
The user signs in and approves there and the connection completes by itself. A
backup link comes back in `consentUrl` for the rare case the page did not open
in front of them.

**Paste** (`path: "paste"`), when no on-host browser is reachable, such as a
browserless brand or a phone. Register returns a consent URL to relay. The user
opens it, signs in, and approves; the loopback page then fails to load, which is
expected, because the vendor app is a Desktop client that redirects to a
loopback address the device cannot reach. The address bar still carries the
code, so they copy the whole URL from it and pass it to
`google-account-register-submit`. The tool pulls the code out of a whole URL, a
bare code, a `code=...` fragment, or a value containing `%2F`.

Either way, read the outcome back with `google-account-register-poll`, per the
result's `readback` field: `registered` names the connected email, `pending`
means it has not completed, `expired` means the code timed out and register
should be re-run. Do not tell the user they are connected before poll returns
`registered`.

The register tool does not block and does not wait. A second register while one
is already live is refused rather than overwriting it; that refusal names how
long the live one has been pending.

One account can connect several Google accounts. Every operational tool takes an
optional `account` (the email address). Omit it when there is one; with two or
more, omitting it returns a refusal listing them.

## Reading and controlling

- `google-calendar-list` — events in the next `rangeDays` days (default 7).
  Recurring series are expanded into instances, so this returns what the user
  actually sees in their calendar.
- `google-calendar-event` — one event by id.
- `google-calendar-create` — times are RFC3339 (`2026-07-20T09:00:00Z`) unless
  `allDay` is true, in which case they are plain dates (`2026-07-20`).
- `google-calendar-update` — changes only the fields you name. Omitted fields
  are left alone, so an update never blanks something by accident.
- `google-calendar-cancel` — deletes the event and notifies attendees.
- `google-calendar-respond` — accept, decline or tentatively accept an
  invitation. Every other attendee's response is preserved.
- `google-calendar-freebusy` — busy blocks between two times. Free time is the
  complement, which you work out.

## Checking health

`google-account-list` answers "is anything connected?" from local stores with no
network call. Read each entry's `status` — a count alone is not "connected", and
an unreadable store still counts. `google-account-info` gives one account's auth
state, including whether its access token is near expiry and which scopes were
granted.

## When something is wrong

**"GOOGLE_CLIENT_ID is not configured"**, or the same for
`GOOGLE_CLIENT_SECRET` — this brand has no Google vendor app yet. Nothing the
operator does on the device fixes it, and no file placed on the device fixes it
either: the id and secret ship in the brand's `brand.json` and reach the device
through an installer publish. The brand needs its own Google Cloud Desktop OAuth
client. See `platform/plugins/google/references/auth.md`.

**A 403 naming insufficient scope** — the connected account approved something
narrower than the full calendar scope. Re-run `google-account-register` so they
re-consent.

**The user re-consents every week** — the brand's Google app is still in
Testing, where Google caps refresh tokens at seven days and limits the app to
100 named test users. Completing brand verification removes both. This is a
property of the vendor app, not a fault in the connection.

**A registration never completes** — the consent URL was opened but not
approved, or the code was never copied back to
`google-account-register-submit`. `google-account-register-poll` reports
`expired` once the pending entry outlives Google's code lifetime.

---
# QuickBooks Online
Source: https://docs.getmaxy.com/quickbooks.md

# QuickBooks Online

Connect QuickBooks Online so the agent can read your books and create records — invoices, customers, bills, payments, and reports — over Intuit's REST API.

QuickBooks Online is the cloud product. Your books live in Intuit's cloud, so every request proves two things: that the connecting app is yours, and that someone authorized it to touch a specific company. Real Agent ships to your own machine and never runs a shared backend, so it cannot hold one master QuickBooks identity for everyone. Instead you bring your own Intuit app, and its keys are stored only on your install.

## What you do once

**1. Register an Intuit app.** Go to [developer.intuit.com](https://developer.intuit.com), create an app, and turn on the Accounting scope. Copy the app's **client ID** and **client secret**. In the app's OAuth settings, add a **redirect URI**: your public agent address followed by `/api/quickbooks/callback`. Intuit does not allow `localhost` for live use, which is why the address is your public one. Start with the free **sandbox** keys (Intuit gives you up to five practice companies) before moving to real books.

**2. Hand the keys to the agent.** Tell the agent your client ID and secret, and whether they are sandbox or production. The agent stores them on your install only.

**3. Authorize each company.** Ask the agent to start a connection. It gives you a link; you open it, sign in to Intuit, and approve one company. That is the only manual step per company. An accountant with several companies under one app just repeats this for each.

## What the agent does after that

Everything else is automatic. The agent keeps the connection alive on its own, refreshing access behind the scenes, and can:

- **Read** anything — "show me unpaid invoices since January", "list my customers", "what bills are due this week".
- **Pull reports** — profit and loss, balance sheet, aged receivables, for any date range.
- **Create and update records** — raise an invoice, add a customer, enter a bill, record a payment.

## Keeping it healthy

A QuickBooks connection goes stale if it sits unused for about 100 days, or if you revoke access from your Intuit account. Ask the agent to run a connection audit now and then; if a company shows as not alive, just re-authorize it with a fresh link. Reading has a generous free monthly allowance and writing is free, so the agent favours precise lookups over pulling everything.

## Moving to real books

Switching your Intuit app from sandbox to production requires passing Intuit's own production security review. That is between you and Intuit; it is not something the agent does for you.

---
# JobLogic
Source: https://docs.getmaxy.com/joblogic.md

# JobLogic

> **Status: Suspended.** This connector is not shipped on any brand. Its skill triggers on the generic word "jobs", which collides with SiteDesk's `Job` graph node and mis-routes every generic jobs question to a connector that ships inert. It is excluded from every brand payload (`plugins.excluded`), and the installer de-registers it on any install that still carries it. The source stays in-tree; re-introduction reverts the exclusion and reworks the trigger surface under a future task. Everything below describes the connector as designed, for when it is re-introduced.

Connect JobLogic, the field-service management system, so the agent can read and update your jobs end to end — customers, sites, jobs, engineers, visits, quotes, invoices, assets, and timesheets — over JobLogic's REST API.

This connector is built for one team that already runs JobLogic as its system of record. Your field staff send a voice note or message to Real Agent from the field; Real Agent works out what they mean and makes the JobLogic update for them. Your staff never touch the JobLogic API themselves, so only Real Agent's own connection talks to JobLogic.

## What you do once

**1. Ask JobLogic for API access.** Raise a ticket with JobLogic Support for API credentials. Ask for a test (UAT) account first, then production. They give you a client ID, a client secret, and your company tenant id.

**2. Whitelist your connection.** JobLogic only accepts API calls from network addresses you have registered with them. Real Agent makes its calls from the machine it runs on, so that machine's public address is the one to register. If the machine moves between networks its address can change, so either run it from one place with a stable address, send its traffic through a fixed address, or ask JobLogic whether production needs this at all. If the address is not registered, calls are refused and the agent will tell you the connection was blocked.

**3. Hand the keys to the agent.** Tell the agent your client ID, secret, tenant id, and whether they are test or production. The agent stores them on your install only and never repeats them back.

## What the agent does after that

Everything else is automatic. The agent keeps the connection alive on its own, refreshing access behind the scenes, and can:

- **Read** anything — "what jobs are open for this site", "show this engineer's visits this week", "list unpaid invoices".
- **Write** from a field instruction — create or reschedule a visit, log labour and materials against a job, set a job's status, approve a quote, raise an invoice, add a note or a photo to a job.

Because JobLogic's credential belongs to the company rather than to a person, the agent stamps each update with the engineer who reported it. You give the agent a list that maps each staff member's phone number to their JobLogic engineer record once, and it does the rest.

Each create carries a safety key, so if a message is retried or a reply goes missing the same instruction never creates a duplicate record.

## Testing

Do all your testing against the JobLogic test (UAT) environment first. Switch to production only once the test environment behaves the way you expect and your production keys and whitelisting are in place.

---
# Connector
Source: https://docs.getmaxy.com/connector.md

# Connector

Connect any REST API by name so the agent can call it on your behalf — without a bespoke integration built for that one service. You register the API once with its address and a credential; from then on the agent can read from it and write to it, and the credential stays sealed inside your install.

This is the general-purpose bridge to the long tail of services that do not have their own dedicated connector. If a tool you use exposes a REST API and issues an API key or token, you can wire it up here.

## What you do once

**1. Get a credential from the service.** Most APIs give you an API key, a bearer token, or a username and password. How you get it depends on the service — usually a settings or developer page.

**2. Register the connector.** Tell the agent the service's name, its base web address, and the credential. The agent stores the credential in your install's protected secrets area. It is never shown back to you, never written into any project file, and never appears in logs.

**3. That's it.** The connector is ready. You can register as many as you like, each under its own name.

## What the agent does after that

- **Calls the API for you** — fetches data (GET) and makes changes (create, update, delete) against the service, with your credential attached automatically.
- **Stays on the registered service.** Every request is locked to the address you registered. A request that tries to reach a different site, an internal network address, or follows a redirect off the registered service is refused. The credential can only ever be sent to the service you pointed it at.
- **Lists what's connected** — the names and addresses of your connectors, on request. Never the credentials.
- **Disconnects on request** — removes a connector and erases its stored credential.

## Staying safe

The credential lives only inside your install, in the same protected store the other connectors use, readable only by your account. The agent injects it into each request and never repeats it back. A background check periodically confirms no credential has leaked into a log and that every connector still has its credential.

Because a connector can make authenticated requests to a live service, registering and using them is an admin action — done by you or through your admin agent, not handed to a public or assistant agent.

## What it can and can't reach

- **Can:** any HTTP or HTTPS REST API that authenticates with a bearer token, an API key in a header, or basic username-and-password.
- **Can't (yet):** services that require an OAuth sign-in dance or refreshing tokens — those need a different kind of connection. It also does not speak GraphQL or older SOAP APIs.

Once an API is connected, the actual workflow you want — "turn approved timesheets into an invoice", say — is something you teach the agent as a skill. The connector is the plumbing; the workflow is yours to define.

---
# Calendar and Booking
Source: https://docs.getmaxy.com/calendar-booking.md

# Calendar and booking

Real Agent gives you a calendar of your schedule and a public page where people can book time with you. The calendar and the booking page are two sides of the same data: a booking someone makes shows up on your calendar straight away.

## The admin calendar

Open `/calendar` on your Real Agent address (the same sign-in as the dashboard). It shows your schedule in four views:

- **Day** and **Week** show an hour grid with each timed entry as a block.
- **Month** shows the familiar month grid with a few entries per day.
- **Year** shows twelve small months; days with something on them carry a dot, and clicking a month opens it.

Use **Today** and the arrows to move around, and the view buttons to switch. The calendar reads everything time-bound in your graph: meetings imported from a calendar export or created by a booking, appointments Real Agent schedules for you in chat, and tasks that have a due date (shown on their day).

Click any entry to open its details: the full title, time, location, and who is attending, with each attendee on its own line (name, and email beneath it). For a booked meeting you can also edit its title, time, or location, or delete it, and the grid updates straight away. You can **Download** a booked meeting as a calendar file (`.ics`) to add it to Apple, Google, or Outlook calendars; when the meeting has attendees, Download asks whether to include them in the file before it saves, and a meeting with no attendees downloads straight away. The private note is never written into the downloaded file, since the file can travel outside your admin. In the edit view you can add an attendee (give a name, an email, or both) and remove one; adding someone who is already a contact reuses that contact rather than making a duplicate, and removing them takes them off the meeting but keeps the contact. The edit view also has a private note for that meeting, a place for your own reminders that only you and your team ever see; it never appears on the public booking page or anywhere outside your admin. Appointments Real Agent schedules and due-dated tasks are shown for detail only and are changed where they are created, not here. New meetings are still made by a booking or by asking Real Agent in chat, not from the calendar.

Everything on the calendar comes from records the rest of Real Agent already uses, scoped to your account.

## The public booking page

The booking page is a separate, public site on its own web address (for example `book.yourdomain.com`). A visitor sees only open time slots, never the titles or details of your existing meetings. They pick a slot, enter their name, email, and an optional note, and submit. That is the whole flow, like a simple Calendly.

The page is hosted on Cloudflare so it keeps working and keeps taking bookings even if your Real Agent device is briefly offline. Each submission is stored safely at the edge first, then turned into a meeting on your calendar shortly after.

## Putting the booking page live

Ask the admin to run the **calendar-site** skill ("put up a booking link" or "deploy the booking site"). It will ask for:

- your **time zone**,
- the **meeting length** (for example 30 minutes),
- a **buffer** between meetings (for example 10 minutes),
- your **weekly availability** (which days and hours you take bookings),
- the **web address** the booking page should use.

It writes those settings, builds the page, puts it live on that address, and checks that a test booking is captured. To change your availability later, run the same skill again with the new numbers.

## How a booking becomes a meeting

1. A visitor submits a booking on the public page.
2. The booking is saved immediately at the edge (so it is never lost).
3. Your Real Agent device picks it up on a short cycle, creates a `:Meeting` for it, and links the visitor as a contact.
4. You are notified by email (and by WhatsApp only if you already have a thread with the agent; it never starts a new WhatsApp conversation).
5. The new meeting appears on your `/calendar`.

This usually completes within a couple of minutes of the booking.

## If a booking does not show up

Bookings are reconciled on a cycle and the system audits itself each run. In the device logs:

- `[calendar-booking]` lines trace one booking through submit, write, notify, and done.
- `[calendar-reconcile]` lines report four health numbers per account: `eligibleExamined` (bookings the check looked at), `pendingUnreconciled` (bookings captured but not yet turned into meetings), `pendingOldestAgeMin` (how many minutes the longest-waiting of those has been waiting) and `orphanMeetings` (booking meetings with no matching saved booking). Entries that only block out a slot are not counted — they are not bookings and never become meetings.

A `pendingUnreconciled` number that stays above zero across several runs means those bookings are failing to turn into meetings, and the `attempt=` number on the matching `[calendar-booking]` lines says how many times each has been tried. A booking whose meeting is deleted is picked up again automatically on the next run. To read them: `journalctl --user -u <brand-service> | grep -E '\[calendar-booking\]|\[calendar-reconcile\]'`, then filter by one booking id for its full story. The edge half of a booking (before your device sees it) is in the Cloudflare Pages function logs.

A count on its own cannot tell a short lag from a long one: five bookings waiting two minutes and five waiting nine hours are both `pendingUnreconciled=5`. `pendingOldestAgeMin` is what separates them. It reads `0` when nothing is waiting, and also when something is waiting but for under a minute, so read it together with the count beside it.

A separate `op=liveness` line is printed every ten minutes by your device's own server, whether or not anything is wrong. `stale=false` means the booking check has run recently. `stale=true` means it has not run in over six minutes, which is the case nothing else can tell you about: a check that has stopped cannot report that it has stopped. `lastSkipReason` names the last account whose check finished without looking at any bookings, and reads `none` when every account it got to was checked. If you run more than one account, a check can both look at one account and skip another, so seeing a recent check time next to a skip reason is normal and means two different accounts.

## When your real calendar lives elsewhere

The booking page publishes open slots from the internal calendar, the one Real Agent keeps in its own graph.
If you also keep commitments on a connected calendar (Google Calendar or Microsoft 365 through a
claude.ai connector), those live in the admin session. So the booking page does not offer a time you are
busy on the connected calendar, the admin session reads the connector's free/busy every 30 minutes and
reflects those busy blocks into the availability the page publishes from. The reflection is read-only: it
holds the connector's busy times against the booking page and never writes anything back to your Google
or Microsoft calendar.

This runs from the admin session because it is the only seat that can see both your connected calendar and
the internal booking calendar at once. When you ask what you have on or whether you are free, the admin
reads the connected calendar directly and answers from the full picture. A specialist cannot do this: it
never sees the connected calendar, so it would answer from the internal calendar alone. If the sync ever
falls behind, the reconcile run reports it as a drift count so it is caught before a visitor books a slot
you are not really free for.

## What this does not do

One meeting type only (one length, one weekly window, one buffer). Your meetings in Real Agent are the source of truth for the booking page, unless your real calendar is a connected calendar (Google Calendar or Microsoft 365), in which case its busy times are reflected into the page as described above. iCloud and other calendars are not synced. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows open times from a snapshot your device publishes to the edge on a short timer, so the page keeps showing availability and taking bookings even while your device is offline — a slot that gets booked drops off at once, and times that have passed are never shown. If your device stays offline a long time the open times can go stale, but they never vanish; they refresh the moment it is back online.

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

# LinkedIn Extension — operator guide

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

## Install (one time)

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

## Use

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

## What lands in the graph

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

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

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

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

## When the pill turns amber

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

## When the pill turns red

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

## Related plugins

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

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

# Admin Chat Attachments

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

## How files get in

You can drag-and-drop a file, pick one with the paperclip, or paste. All three routes stage the file the same way and enforce the same caps. Pasting a file you copied from your file manager (or another app) keeps its real name and type, so `report.pdf` stays `report.pdf`. Pasting a screenshot or a raw clipboard image has no filename to keep, so it is staged under a generated unique name like `pasted-image-1700000000000.png`. Pasting plain text still types the text into the message box as before.

## Accepted file types

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

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

## Size caps

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

## What happens with a zip archive

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

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

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

## What is **not** supported

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

## Where the files live

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

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

# AEO (Answer Engine Optimisation) — user guide

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

## When to use it

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

## The tools

### `aeo-emit-jsonld`

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

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

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

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

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

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

### `aeo-audit-page`

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

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

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

## The eight heuristics

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

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

## Observability

Every tool emits a single log line per invocation:

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

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

## What this plugin does not do

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

## See also

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

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

# Insight pass

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

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

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

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

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

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

# Visitor graph

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

## What this gives the operator

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

## Data model

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

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

## How identity gets resolved

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

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

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

## Tools

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

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

## Privacy

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

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

## Verification

Quick checks the operator can run after deployment:

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

## Failure signals

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

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

# Voice Mirror Guide

## What It Does

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

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

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

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

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

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

## How Your Voice Is Captured

### Automatically — from chat

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

### Via backfill — from historical content

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

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

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

- **Chat archives** — tag a whole conversation in one click. Real Agent doesn't ask about individual messages because chunks within a conversation almost always share the same voice. Options per conversation: yours, mixed (multiple authors on your side — rare), or not yours (e.g. a Slack channel where you only forwarded other people's messages).
- **Documents and posts** — paginated 10 at a time. Real Agent shows each item with its source, date, recipient or topic, and a one-line preview. You name the format on every command — Real Agent never guesses it. Reach for the broad buckets, `formal` or `informal`, unless a narrower one (`email`, `article`, `note`, `social-post`, `marketing-copy`, `text`) genuinely fits. 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, Real Agent polished).

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

## Distilling Your Profile

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

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

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

## Your Voice and the House Voice

There are two kinds of profile. **Your voice** is learned from your own writing — it's what an email drafted under your name should sound like. **The house voice** is learned from everyone's writing on the account as a whole — it's what something that goes out under the business's name should sound like, like a brochure or an investor document.

Drafting picks the right one for you: emails use your voice; brochures and investor documents use the house voice. If you ask for your own voice but haven't tagged any of your own writing yet, Real Agent quietly uses the house voice instead, so drafts still sound right rather than generic.

When you tag content, it's attributed to you by default. On a shared account with more than one person, you can attribute a piece to whoever actually wrote it, so each person's voice stays their own while the house voice draws on all of it.

## Feedback Loop

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

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

## Opt-Out Per Skill

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

## What It Won't Do

- **Blend two named people** — you get a profile per person plus one house voice for the whole account, but not a bespoke "Joel + Neo combined" voice.
- **Copy public figures** — voice mirror only learns from your own writing.
- **Clone audio** — text only, no speech synthesis.
- **Guess** — historical content stays `unknown` until you mark it. Real Agent never auto-classifies your writing. (Automatic ingestion applies only to your live PTY sessions where authorship is certain.)
- **Invent an identity** — a voice belongs to a real admin user on the account. Attribution is refused outright if it names anyone else, and nothing is written. A person's name is not an identity, so tagging someone's writing on a shared account needs their actual admin id, not their name.

## Status

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

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

# Admin UI reference

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

## Scope and tree decision

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

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

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

## UI conventions

**Icons are Lucide components only — no emoji.** Every glyph rendered in
`platform/ui` JSX (the `/chat` web chat, the WhatsApp/channel readers, the
admin shell, the `/data` grid) is a `lucide-react` component (e.g.
`<Paperclip>`, `<FileText>`), never an emoji/emoticon character in the rendered
string. The rule is enforced at build time by
[`scripts/check-no-emoji-in-jsx.mjs`](../../../ui/scripts/check-no-emoji-in-jsx.mjs),
which fails `prebuild` if any emoji codepoint appears in a `.ts`/`.tsx` file
under `app/` (tests excluded). Inline an icon at the surrounding text size and
colour (`size={14}` matches body text; Lucide inherits `currentColor`).

## Admin Hono routes

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

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

### Sessions and chat

| Mount | Purpose | Key methods |
|---|---|---|
| `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
| `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
| `/sidebar-sessions` | Sole data path for the sidebar Sessions list. One JSONL on disk equals one row. The row's delete button 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 — 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. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name>  <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss, so a Sessions row and that conversation's reader-panel row read identically. A channel row name has two sources, in precedence order. First the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, which matches the sender phone against the account's admins — that names an admin WhatsApp session and every Telegram session. Second, for a `whatsapp` row only, the WhatsApp pushName from the account's on-disk message store (`storePushNamesBySender` over `readConversationSummaries`, keyed by `normalizeE164` of the counterparty phone so a `+44 7…` sender matches a `447…` store key). The store is consulted only when the resolver reports **no binding** for that sender. That is typically a public WhatsApp customer, the case this exists for — such a sender has no admin record and so can only be named by the store, and without it those rows degraded to the bare number while the reader panel named them — though an admin missing from `users.json` qualifies too. A resolver miss caused by Neo4j being unreachable deliberately does NOT qualify: that is an outage rather than an absent binding, so the row degrades to the number and still logs `reason=neo4j-down`, keeping the outage signal intact. Telegram has no message store and is never looked up. The store read is guarded: a corrupt or absent store degrades to no pushName and logs `op=store-unreadable`, never 500s the list. Both sources are the ones the reader itself uses, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The Sessions list reads the viewed account's own store only. An account-manager's thread persists under the house account rather than the sub-account it manages, so on a multi-account install viewing a sub-account that row is not named here even though the reader panel names it — the reader additionally consults the house store, filtered to manager phones bound to that sub. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> (operator=<n> store=<n>) unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line — emitted only when **both** sources miss, so a residual shortfall means a genuinely nameless sender rather than the expected public-customer floor, and the `operator=`/`store=` split says which source is carrying the names. | `GET /` |
| `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`. Sends **no** accountId. The manager resolves the session by scanning every slug under `projects/` for its JSONL (`slugForExistingJsonl`, covering `archive/`), exactly as the sidebar list enumerates rows, and gates on file existence alone — so any session the operator can see is deletable. This replaces the earlier accountId-derived slug and the `!row` term, which narrowed resolution below the list and left a session under a non-house slug with no watcher row (e.g. a legacy `-home-admin` session on a single-account install) listed yet un-deletable. Account scope is a view filter, not a delete gate — admin access is install-wide, so there is nothing to scope against. The sibling `/session-stop` (the standalone End control) resolves identically and also forwards no accountId. | `POST /` |
| `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. On `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
| `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |

**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 reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. **For admin sessions, permission mode is fixed (Bypass) and not operator-chosen, while model and effort ARE operator-chosen at launch** via the composer's `+` new-session popover — a radio model list (Opus / Sonnet / Haiku) plus a 5-stop Faster→Smarter effort slider (Low / Medium / High / Extra / Max, where `max` applies via the `--effort` argv rather than `settings.json`); the popover description lives in `.docs/admin-webchat-native-channel.md`. Absent a pick (and on every resume), the daemon-pinned defaults govern: the `/chat` composer *footer* carries no live re-seat pickers — `rc-daemon.ts` pins settings.json `model`/`effortLevel`/`permissions.defaultMode` to `claude-opus-5[1m]` / `medium` / `bypassPermissions` regardless of `account.json` (read by the rc-spawn webchat path), and `pty-spawner.ts` forces the claude.ai/code PTY `--permission-mode` to `bypassPermissions` for `role=admin`. Public sessions are fixed to `claude-sonnet-5` (the `/public-spawn` chokepoint + `buildPublicWebchatSpawnRequest`), keeping `dontAsk`. **WhatsApp admin sessions override the daemon default:** the `/rc-spawn` handler pins `claude-sonnet-5` / `--effort max` / `bypassPermissions` for a `role=admin` WhatsApp spawn (constants `WHATSAPP_ADMIN_MODEL`/`WHATSAPP_ADMIN_EFFORT` in `http-server.ts`), pushing `--model`/`--effort` on both the fresh spawn and every resume so the params hold for the session's whole life. The override is scoped to sessions *born* under it: a fresh WhatsApp admin spawn stamps its classification sidecar `model=claude-sonnet-5`, and a resume re-asserts Sonnet/max only when that stamp is present — so a WhatsApp session created before this change (sidecar `model=null`) finishes its life on the Opus/medium default. Webchat and Telegram admin sessions are unaffected; public WhatsApp inbound never spawns. The composer footer instead renders a **read-only** params row (mode · model · effort labels) plus a context-window usage figure (`pct% · ~used/~window`), all sourced from the session pointer; the full path is in `.docs/admin-webchat-native-channel.md`. The composer cannot re-seat a session, so the "no transcript" 400 on a brand-new chat is unreachable; the `/api/admin/session-reseat` + `/api/webchat/settings` routes and the dashboard re-seat form stay as off-composer surfaces. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.

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

**Session row delete, rename, and select mode.** The sidebar Sessions row supports three operator affordances. **Rename** (kebab → Rename) opens the inline edit input with its existing title pre-selected on focus, so the first keystroke replaces the whole title rather than appending to it — on both the sidebar (`conv-name-edit`) and the `/chat` Conversations flyout (`op-conv-rename-input`). **Delete** (kebab → Delete) arms a confirm that names the session (its label plus the 8-char id prefix) and renders as a popover anchored beside the row it will delete — reusing the same `AnchoredPopover` the kebab menu uses, not the shared bottom-right info dock — so the target is unambiguous; it still states the delete is permanent and, for a live row, that the session is stopped first. **Usage & cost** (kebab → Usage & cost) opens the per-day active-time / token / GBP-cost table for that session, and anchors beside its row through the same `AnchoredPopover`, with no scrim, so the rest of the dashboard stays legible and interactive while it is open. `AnchoredPopover` right-aligns to the anchor's rect and clamps horizontally against both viewport edges. Vertically it places below when the popover fits there, above when it fits there instead, and otherwise on whichever side has more room — and in every case it bounds the popover's height to the room on the side it chose. That bound is what keeps a long session's panel anchored: a 30-day usage table is taller than the viewport, and clamping it to fit would push it up over the very row it describes, which is not "anchored" to anything. Instead it sits flush against its row and scrolls internally. Because the bound is derived from the anchor rect and the viewport rather than from the popover's own height, the `ResizeObserver` pass it triggers recomputes the same values and settles in one extra pass. The horizontal right-edge clamp does not engage for a left-docked sidebar row (a popover wider than its anchor overflows left, which the left clamp handles); it is kept because the component is general-purpose and is covered by unit test only. Positioning is instrumented, because a mispositioned client-side panel throws nothing and the operator just sees a menu item that did nothing: every positioning pass (each open, each `ResizeObserver` callback, each window resize) emits `[anchored-popover] op=anchor id=<class>:<sessionId> present=<bool> rect=<x,y,w,h>` then `op=placed … maxH=<px> flipped=<bool> clampedLeft=<bool> clampedRight=<bool>`, and `op=offscreen` at `console.error` when the anchor is detached from layout or a placed rect misses the viewport. A pass that computes exactly what the previous pass did is suppressed, so mount and its trailing `ResizeObserver` callback log once rather than twice; the `op=offscreen` assertion is never suppressed. A healthy open is `present=true` with a non-zero rect followed by `op=placed` and no `op=offscreen`. `present=false` means the row is not in `rowRefs` — the row unmounted or its ref callback did not run — and the panel docks bottom-right rather than vanishing, because an invisible panel is indistinguishable from a broken menu item. An all-zero rect means the anchor is detached. The assertion is gated on the popover having non-zero measured size, so an unlaid-out popover (pre-paint, or jsdom) stays silent; a genuinely zero-size popover in production is therefore not reported. **Select mode** (a "Select" control in the Sessions header) turns each row into a checkbox and shows a "Delete N" bar; the row body toggles selection instead of opening, the per-row kebab is hidden, and confirming a count-based `BulkDeleteConfirmModal` (which names N and how many are live) loops the single-id `session-delete` over the selection (no route change — `/api/admin/session-delete` stays single-id). Bulk deletion is instrumented with a `[admin-ui] sidebar-bulk-delete op=start count=<N> live=<M>` / `op=done requested=<N> ok=<X> failed=<Y>` envelope around the per-session `sidebar-session-delete` lines, so a silently-dropped item shows as `ok+failed < requested`. Outside select mode the single-delete and row-open behaviours are unchanged.

**`/chat` empty-state starter chips.** While the admin webchat has no conversation JSONL yet (`GET /api/webchat/session` → `projectDir:null`), `app/chat/page.tsx` renders a "What's up next, {givenName}?" greeting (given name = first whitespace token of the admin session's `userName`; degrades to "What's up next?" without one) above three one-tap starter chips (replacing the earlier read-only lists): **Catch me up** (`Inbox`), **My day** (`CalendarClock`), **To-do** (`ListTodo`). `GreetingPanel` no longer fetches `GET /api/webchat/greeting` — the headline reads from the `userName` prop alone. A tap dispatches the chip's verbatim prompt through the existing `send()` with zero typing, via the same arm/fire path as the `?q=` handoff seed: `dispatchChip` logs `[admin-ui] op=chip-tap id=<catch-up|my-day|to-do> chars=<n>`, sets the composer text, and arms a one-shot effect that fires `send()` once the text matches. Chips gate to `variant` admin/operator; the public surface shows the headline alone. The first journaled turn replaces the panel with the transcript. The `/api/webchat/greeting` route (`server/routes/webchat-greeting.ts`) and `specialist-roster.ts` are now unconsumed by this surface — slimming them is a separate follow-up. The full webchat architecture lives in `.docs/admin-webchat-native-channel.md`.

**`/chat` composer transport button.** The button at the end of the input shows a **microphone** while the box is empty — tap it to record a voice note. Type a character, or attach a file, and the same button becomes the **send** arrow; while the agent is replying it becomes a **stop** square. So an empty composer offers voice, a composer with something to send offers send, and a running turn offers stop — one button, three states. The microphone-device chooser (which input to record from) stays in the row of icons below the box. This is the same on the admin `/chat`, the public chat, and the maxy-lite webchat.

**`/chat` Conversations flyout + zero-sessions splash.** The admin `/chat` `HeaderMenu` carries a **Conversations** item (rendered only when `onOpenConversations` is wired, which scopes it to `/chat`) that opens an in-chat session-management pane (`app/chat/SessionList.tsx`) hosted by `chat/page.tsx` — distinct from, and sharing endpoints with, the `Sidebar` Sessions list (which stays hidden on narrow viewports). It enumerates the admin's own webchat sessions via `GET /api/admin/sidebar-sessions` (carries the per-row `live` marker and `archived` flag, install-wide), **filtering out `channel:'whatsapp'`/`'telegram'` rows** (those are channel-bound, cannot be chatted in webchat, and live in the WhatsApp/Telegram reader panels; the same endpoint now also feeds the sidebar Sessions list, which does show them), lists the remainder newest-first with the live one marked and archived rows folded under a collapsible subsection, and offers per-row resume (`/chat?session=<id>`), rename, archive/unarchive, two-tap delete, an **End** control on the live row (`session-stop`), plus a New-session control (`session-rc-spawn`) and a copyable full id. The pane header carries the title and a top-right close (X) control that dismisses the flyout (no back-chevron/"Chat" label); the New-session control floats at the pane's bottom-left. When the canonical pointer is `known:false` (`canonical-empty`) and enumeration returns zero rows, the surface renders a splash (brand logo + New session) instead of a dead bootstrap thread; a freshly-spawned New session is `known:true`, so its greeting is preserved. Client breadcrumbs: `[chat-conversations] op=enumerate owned=<n>` and `op=action name=<open|rename|archive|delete|stop> sessionId=<id8>` (`op=action-failed … status=…` on a non-2xx). Detail lives in `.docs/admin-webchat-native-channel.md` ("Conversations flyout + zero-sessions splash").

**`/chat` header always shows the account name.** The admin/operator `HeaderMenu` centre always renders the active account's name alongside the brand logo, on every chat surface, regardless of the active conversation — `headerTitle = businessName || brand.productName`. There is no per-conversation header title: the active conversation's label stays in the sidebar and the Conversations flyout (each resolves its own per-session title independently), never in the header. The account name is `LocalBusiness.name`, resolved by `fetchAccountName(accountId)` in `neo4j-store.ts` with **no theme gate** — unlike `fetchBranding` (which returns null unless a colour/logo property is also set, and stays the source for actual theming). The house account is seeded with `LocalBusiness.name = <brand productName>` (`seed-neo4j.sh`, coalesced ON MATCH so a re-seed backfills a name-less node and never clobbers a name the business-profile recorder set later), so the sub-account picker's house row shows the product name (e.g. "SiteDesk"), not the raw accountId UUID. When a name is absent the resolver emits `[branding] account-name-fallback accountId=<id8>… reason=no-name`, and the picker falls back to the accountId; the seed + backfill is the name source. The lite variant header is unchanged.

**`/chat` sub-account switch starts a fresh conversation.** Switching sub-accounts from the header picker while on the chat page presents an empty greeting with no active session — no footer id, no highlighted conversation. The next message starts a **new** conversation for the switched-to account rather than reopening that account's previous one, and the previous conversation is left untouched and still resumable from the Conversations list. A normal visit to the chat page that did not switch accounts still reopens the account's most recent conversation as before. The full mechanism (the `clearSignal` first send and the `op=post-switch-pointer`/`op=send-target-resolve` diagnostics) lives in `.docs/admin-webchat-native-channel.md`.

**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.

**One delivered bubble per turn when the agent replied.** On the admin `/chat`, a turn that used the reply tool shows exactly one delivered bubble — the reply. A trailing sign-off or recap the agent writes after the reply is treated as narration and is not shown as a second bubble (it collapses like any mid-turn prose). Only a turn that ends **without** using the reply tool has its final text promoted to a delivered bubble, so a genuine no-reply answer is never lost. The tradeoff, accepted by the operator: a turn that acknowledges on the reply tool and then writes the real answer as trailing text will not show that trailing answer — the agent is steered to put the answer on the reply tool so this stays rare.

**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is authorised server-side (`authorizeReplyFileRef`) against the send's own account (the conversation owner) **or** the session's pinned account (`getAccountIdForSession` — the exact scope the DeviceFilePicker used to offer it), so a house/managing-admin session can attach a managed-client file the picker listed. Because a reply sends bytes OUT to an external WhatsApp recipient, any other account, a `public` partition, and any install-wide / non-account path are refused fail-closed; the cross-account admission's authority is the session's pinned account, never the caller-supplied body `accountId` (the own-account admission matches the body `accountId`, which the route already trusts via `isValidAccountId` and the existing-conversation guard). A scope refusal returns a distinct HTTP 403 reason (`file-ref-cross-account` / `file-ref-public` / `file-ref-foreign`) and copy, never "device file not found" (reserved for a genuine resolve/stat miss). Per-ref `[wa-manual-send] op=reply-file-ref … via=own|session|deny reason=<…> ok=<bool>` logs the decision before any send; `via=deny ok=true` is the exfiltration signature that must never appear. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.

**`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).

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

**Public visitor surface.** The public-host root (`GET /`), the `/:slug` agent routes, and the admin-host `/public` / `/public-chat` previews all serve one shell, `public.html` → [`app/public-entry.tsx`](../../../ui/app/public-entry.tsx) → `PublicChat`. `PublicChat` reuses `useSession` purely as the magic-link gatekeeper — it drives `AccessGate`, resolves the agent slug from the path, and handles `?token=` verification; once the grant is satisfied it mounts `ChatSurface variant="public"`, whose transcript reads the visitor-scoped, **delivered-only** stream `GET /api/public-reader/stream` ([`server/routes/public-reader.ts`](../../../ui/server/routes/public-reader.ts)) — the visitor sees the agent's delivered prose, never the tool/tool-result/directive bytes the legacy `/api/chat` SSE render exposed. The human visitor's branding is resolved client-side by `useSession`. For the head only, the server injects per-agent link-preview meta — `<title>`, `og:title`/`og:description`/`og:image`, `theme-color`, and a per-agent favicon, resolved from the same branding cache `useSession` reads — into the served shell, so a link-preview crawler (which never runs the client bundle) gets a branded card per agent instead of one generic shell; an agent with no branding cache gets the clean brand-default shell with no empty meta tags. There is exactly one public client surface and it is 1:1 visitor↔agent: the earlier `?surface=next` A/B handle is retired, group messaging is retired (not a supported product surface), and a former `/g/<slug>` group URL now serves the same 1:1 shell — a stale bookmark gets the 1:1 chat, never a broken render.

### Routines

The Routines surface (`/routines`) is the admin view of the account's
automation `:Event` nodes — the device's scheduled agent turns and tool
dispatches, which the Calendar surface deliberately excludes (`calendar.ts`
unions only appointment events, `actionPlugin IS NULL`). It renders one card per
routine (title, human-readable schedule, next run, channel/destination or tool,
status); a card opens a detail modal modeled on the calendar `EventDetailModal`.
The modal edits the prose instruction (`agentPrompt`, agent-dispatch routines
only) and the schedule via a friendly builder (frequency daily/weekly/monthly,
day-of-week, day-of-month, time) that falls back to a raw-cron field for patterns
it cannot represent.

The route (`server/routes/admin/routines.ts`) is account-scoped like `calendar.ts`
(a foreign-account or non-automation `eventId` 404s):

| Route | Behaviour |
|-------|-----------|
| `GET /api/admin/routines` | Lists automation Events: `recurrence IS NOT NULL OR agentChannel IS NOT NULL OR actionPlugin IS NOT NULL`. Keyed by the `eventId` property. |
| `GET /api/admin/routines/:eventId` | One routine, same automation+account guard. |
| `PATCH /api/admin/routines/:eventId` | Edits `agentPrompt` and/or `recurrence`. Cron validation and the timezone-aware `nextRun` recompute copy the scheduling plugin's `schedule-update` tool. An invalid cron is rejected 400 with no write. Only creation and deletion stay agent-only (`schedule-event`/`schedule-cancel`). |

Observability rides `[admin:routines]`: `op=list accountId=<8> count=<n>`,
`op=get id=<8> found=<bool>`, and `op=patch id=<8> fields=<…> outcome=ok|rejected
reason=<invalid-cron|not-found> nextRun=<…> cron=<serialized>`. A `count=0` while
routines exist for the account is the account-scope-miss signature; the logged
`cron=` on `outcome=ok` confirms the persisted expression without opening the
graph.

### Skills

The Skills surface (`/skills`) is the admin view of the account's operator-
authored skills — the `SKILL.md` files the store-skill tool writes under
`<accountDir>/plugins/<plugin>/skills/<skill>/` (the durable per-account plugin
root, never the immutable `PLATFORM_ROOT/plugins` tree). It renders one card per
skill (name, owning plugin, frontmatter description, reference count); a card
opens a **read-only** detail modal showing the frontmatter, the SKILL.md body,
and the reference filenames. The page is read-only by design — skills are
authored and edited through the chat interface — with one exception: a **delete**
behind an explicit confirm step in the modal. A confirmed delete removes the
skill directory, and the whole plugin directory when that was its last skill.

The route (`server/routes/admin/skills.ts`) walks only the account tree, so
shipped platform skills never appear. Plugin and skill path params are kebab-case
validated (`/^[a-z0-9][a-z0-9-]*$/`) — the traversal guard; anything else 400s.

| Route | Behaviour |
|-------|-----------|
| `GET /api/admin/skills` | Enumerates `plugins/*/skills/*/SKILL.md`, returns `{plugin, skill, description, refCount}` per skill, sorted plugin then skill. A per-entry read error logs `op=list-skip` rather than silently shortening the list. |
| `GET /api/admin/skills/:plugin/:skill` | One skill's parsed frontmatter (`name`, `description`, `publicEmbed`), the SKILL.md body, and the reference filenames. Unknown skill 404s. |
| `DELETE /api/admin/skills/:plugin/:skill` | Removes the skill dir; removes the plugin dir when its `skills/` empties. Returns `{ok, skillDirGone, pluginDirRemoved}` measured after the rm. |

Observability rides `[admin:skills]`: `op=list accountId=<8> plugins=<n>
skills=<n>` (the surface's own heartbeat — `skills=0` against a populated
`plugins/*/skills/` is the broken-walk signature), `op=get plugin=<…> skill=<…>
found=<bool>`, and `op=delete plugin=<…> skill=<…> outcome=ok
skillDirGone=<bool> pluginDirRemoved=<bool>` (`skillDirGone=false` with
`outcome=ok` is a named failure) / `outcome=rejected reason=<invalid-name|
not-found|rm-error>`.

### Agents

The Agents surface (`/agents`) is the admin view of the account's agents. Each
card is tagged by **origin**, which says who put the agent there and therefore
what the operator may do with it. Origin is a different question to `kind`, which
says what shape the agent is: a shipped specialist and a user-created one are
both `kind: 'specialist'` and differ only by origin.

- **Public agents** — the per-agent directories under `<accountDir>/agents/<slug>/`,
  each carrying a `config.json` (`displayName`, `status`, `model`,
  `knowledgeKeywords`) plus the four owned docs `IDENTITY.md`, `SOUL.md`,
  `KNOWLEDGE.md`, `KNOWLEDGE-SUMMARY.md` (written by `public-agent-manager`). The
  public card shows a status dot; its **read-only** modal shows status, model,
  whichever owned docs are present, the knowledge keywords, and an open-agent link
  to the public URL (computed client-side: an `admin.` host maps to the `public.`
  host), plus a **delete** behind a confirm that runs the loud-fail `DELETE`
  (graph projection cleanup precedes file removal).
- **User-created specialists** (`origin: 'specialist'`) — the agent files under
  `<accountDir>/plugins/<plugin>/agents/<plugin>--<name>.md` (written by
  `agent-builder`). The specialist modal shows the model, tools, the owning
  plugin's skills, and the system-prompt body. Specialists are authored, edited,
  and deleted through chat (`agent-builder` / `specialist-management`), so the
  specialist modal offers no delete or open-agent link.
- **Shipped specialists** (`origin: 'shipped'`) — the union of
  `<accountDir>/specialists/agents/`, `<accountDir>/specialists/agents-disabled/`
  and `PLATFORM_ROOT/templates/specialists/agents/`, in that precedence order.
  `sidebar-artefacts.ts` reads the same union minus the quarantine directory, so
  a disabled agent still appears there through its bundled-template row. The
  route once walked only the account plugins tree, so the twelve
  highest-capability agents on the box were the ones the operator could not see.
  These are the only agents that can be disabled, and the route enforces that
  rather than relying on the UI to withhold the control.

The former "Public" flyout toggle in the account menu was removed; this page is
the sole agent surface.

**Risk class.** Every classified card carries a green/amber/red badge derived
from the agent's declared `tools:` line: green when every tool is `read`, amber
when the worst is `write_local`, red on any `exec` or `external`, and red when
any tool resolves to no class at all. Resolution has two sources, because
frontmatter names tools in Claude Code's namespace: a plugin tool is a direct
lookup in `ToolSurface.riskByTool`, which is keyed by the same
canonical `mcp__plugin_<p>_<p>__<tool>` string frontmatter writes, and the
eleven built-ins carry their own table in `server/lib/agent-risk.ts`. An empty
tool list is red: no `tools:` line means the full surface, not none of it.

A public agent carries `risk: null` and shows no badge. Its tool surface
resolves through `ADMIN_CORE_TOOLS` plus the plugin allowlists rather than a
`tools:` line, and classifying its empty list would paint every public agent red
on a premise nothing established.

The detail modal renders one chip per declared tool carrying **that tool's own**
class, so a `read` tool inside a red agent still reads as harmless; a tool with
no class is dashed and uncoloured rather than painted as if it were understood.
Badge colours come from the shared semantic status set: `-solid` with white text
for the badge, `-tint` with `-textOnTint` for a chip, never white.

**Disable.** `<accountDir>/specialists/agents/` is the live dispatchable set —
`spawn-context.ts` reads it to build the spawn manifest — so disabling **moves**
the agent file to `<accountDir>/specialists/agents-disabled/` and records the
basename in a 0600 `<accountDir>/agents-disabled.json`. A flag alone would leave
the agent running while the card claimed otherwise.

The file is moved, never deleted: a premium `--` agent and an operator-edited
override each exist only in the account dir, so deleting one and later restoring
from the bundled template would hand back a different agent than the operator
switched off. A disabled agent stays listed, marked `Disabled`, because an agent
that vanished from the surface that disabled it could never be switched back on.

`provision-account-dir.sh` re-reads the store after its core-specialist recopy
and removes anything named in it, so an upgrade does not silently re-deliver a
disabled agent. An unreadable store withholds nothing and says so: a corrupt
file is not evidence that an agent was disabled.

**Account scope.** The route (`server/routes/admin/agents.ts`) never infers an
account from device state. The two reads resolve from the caller's admin session
(`requireAdminSession` + the shared `accountDirForSession` in
`server/routes/admin/_account-scope.ts`), returning 401 when the session maps to
no valid account. The two writes carry no admin session (the
`public-agent-manager` skill calls them), so they take an explicit `accountId`
query parameter validated against the account list and refuse a missing or
unknown one with 400 — never defaulting to a resolved account. `:slug` params
reject `/`, `..`, `\` (the traversal guard); specialist detail is selected by an
explicit `?plugin=` parameter, not inferred from `--` in the slug.

| Route | Behaviour |
|-------|-----------|
| `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
| `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
| `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
| `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
| `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
| `POST /api/admin/agents/:slug/project?accountId=` | Re-projects the named account's on-disk agent into the graph. Same `accountId` contract as delete. |

Observability rides `[admin/agents]`, and **every line names the account it acted
on** (`accountId=<id8>`) — a well-formed answer about the wrong account is
otherwise indistinguishable from a correct one, which is exactly how an empty
Agents page survived undiagnosed on a multi-account install.

- `op=list accountId=<id8> agents=<n> specialists=<m> skipped=<x> specialistsSkipped=<y>`
  — the heartbeat. Compare `accountId` against the account the operator is
  viewing; `skipped>0`/`specialistsSkipped>0` is a malformed-source signal the
  page renders as a banner, and `specialists=0` with user plugins present is the
  broken-walk signature.
- `op=detail accountId=<id8> slug=<…> kind=<public|specialist>` — with
  `present=<comma-list|none>` on the public branch, where a role absent for an
  agent expected complete is the missing-doc signature. One
  `op=list-specialist-skip plugin=<p> file=<f>` per unreadable specialist.
- `op=<list|detail> auth-rejected reason=no-account-for-session` — the session
  maps to no valid account. No `op=list` line at all for a page load means the
  request never reached the handler, a distinct signature from a zero count.
- `op=<delete|project> rejected reason=<missing-accountId|unknown-accountId>` —
  a caller that did not name its account. A skill that was not updated produces
  this on its first call instead of writing to the wrong account.
- `op=delete accountId=<id8> slug=<…> outcome=<ok|failed>` with
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
  graph-cleanup throw).
- `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
  risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
  disabled=<bool>` — one line per classified agent per listing, so it fires on
  every page load. `riskiestTool` makes a wrong class diagnosable without
  re-deriving it. Its value is the FULL canonical tool name, while the card
  renders only the segment after the plugin prefix. One load
  showing `riskiest tool: memory-update` on the card and
  `riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
  confirmation that both derive from one value.
  **A non-empty `unresolved=` is the one to act on**: the agent
  is forced red, and the cause is that the built-in table or the registry
  mapping has drifted, not that the agent changed.
- `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
  not parse, so every row is forced red and the listing returns
  `riskSurfaceFailed: true`. Without the forced red, every plugin tool would
  land in `unresolved` and a registry outage would read as agent drift across
  every card at once; without the response field, a red row would be
  indistinguishable from a genuinely dangerous agent, because nothing resolved
  so `unresolved` is empty and the per-tool explanation cannot fire.
- `op=disabled-store status=parse-failed reason=<msg>` — the listing then treats
  every agent as enabled, which is what they actually are. A read surface shows
  the true dispatchable set rather than a comforting one.
- `op=<disable|enable> accountId=<id8> slug=<…> outcome=<ok|failed>`, with
  `moved=<bool>` on disable and `reason=nothing-quarantined` on a failed enable.

**Disable is a no-event failure, so it needs a standing check.** If the store
names an agent whose file is still in `specialists/agents/`, nothing throws, the
card reads disabled, and the agent keeps being dispatched. The five-minute
`account-dir-schema-reconcile` pass reports it as
`[fs-reconcile] op=agent-parity account=<id8> disabled=<n> stillOnDisk=<list>`,
fired only on disagreement. The per-cycle heartbeat is `agent-drift=0` on that
pass's summary line, which is what makes the absence of a per-account line
readable as "clean" rather than "never audited" — grep the summary line, not the
per-account one, to confirm the check is running.

### Graph

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

**Node visual encoding.** Every node type that can appear on the canvas renders
a coloured disc with a white Lucide glyph, sized by incident-edge count, with a
matching legend swatch. Colour, shape, and glyph are three per-label registries
in `platform/lib/graph-style/src/index.ts`. Coverage is reconciled against the
true node-label universe, not the colour registry itself: the standing test
`__tests__/icons.test.ts` assembles the universe from the `Neo4j Label` column
of every `schema-*.md` node-type table, the `sourceLabel`/`targetLabel` set in
`typed-edge-schema.ts`, and the `CODE_EMITTED_NODE_LABELS` list, then fails the
build if any member lacks a colour or glyph. Labels that only ever stack on a
base node (`ADDITIONAL_BASE_LABELS`) inherit the base node's colour and are
excluded. A node type carries a coloured glyph even when its text caption is its
bare type: the glyph and the caption are independent channels (caption coverage
is the separate `caption-coverage.test.ts` gate). At runtime, a per-session
`op=icon-fallback` census (emitted from `app/graph/page.tsx` across the initial
render and every live-update poll) names any label that reached the canvas with
no glyph; an empty census is the healthy signal, a non-empty one means a
code-emitted label that no static source declares and should be added to
`CODE_EMITTED_NODE_LABELS`.

### Artefacts and files

| Mount | Purpose |
|---|---|
| `/sidebar-artefacts` | Lists the sidebar Artefacts rows: every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'` — all file types, excluding the separate `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. |
| `/sidebar-artefact-content` | Reads a single artefact's bytes for the artefact pane. |
| `/sidebar-artefact-save` | Persists an artefact edit. |
| `/attachment` | Per-attachment binary fetch (images, PDFs, etc.). |
| `/files` | File browser CRUD (list, download, upload, delete). Listings put directories first, then files newest-first by `mtime` (name tie-break) so a just-changed file leads the panel. Upload is a raw-body stream (file bytes are the request body; `session_key`/`path`/`filename` in the query) written to disk without buffering — ceiling 2 GB (`MAX_DATA_UPLOAD_BYTES`), over-ceiling bodies abort mid-stream with `413`; the client shows a determinate progress bar. Distinct from the 50 MB in-memory chat-attachment cap. |

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

**Outbound file-delivery account scope (WhatsApp / Telegram).** When the agent
sends a file to a channel — WhatsApp `reply-document` or `SendUserFile`, Telegram
`SendUserFile` — the delivery core validates the file path against a single
account directory (`<DATA_ROOT>/accounts/<maxyAccountId>/`) and rejects anything
outside it. That validation scope is the sender's **effective session account**:
the account the session runs in, which for a WhatsApp account-manager routed to a
client sub-account is the gate-resolved sub-account (where the session's files
live), and for every other sender is the house account. It is **not** the
house/channel-routing account from `resolvePlatformAccountId()` (that helper
stays the inbound-persistence scope only). WhatsApp threads the effective account
from the inbound gate through the per-sender doc context (reply-document) and the
native file follower (`SendUserFile`); Telegram has no account-manager routing,
so the session's own spawn account is the scope. A rejected delivery logs
`[whatsapp:outbound] document REJECTED … reason=outside_account_directory
maxyAccountId=<scope>` (and the Telegram twin), so a wrong-account mismatch is
greppable from the log alone.

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

**`/data` upload — button and drag-drop.** Besides the Upload button (one OS-picked
file, stored under a timestamped name), files and whole folders can be dragged
onto the file-browser body to upload into the open folder. A dropped folder's
subtree is recreated under the open folder and every contained file uploaded,
preserving source names and relative paths (a same-named file in an existing
folder is overwritten; empty source subfolders are skipped). The drop region
shows an active outline while a drag is over it and is inert wherever the open
folder is not writable (outside the operator's own `accounts/<id>/…` subtree),
rejecting the drop with the same "Open a folder inside your account to upload"
guidance as the button. Each drop reports an uploaded/skipped summary; a file
that fails the 50 MB limit, the MIME allowlist, or its HTTP write is skipped
individually without aborting the rest, and every uploaded file is indexed for
search exactly as the button path is. The server `/files/upload` endpoint takes
an optional `relpath` form field (the file's path relative to the drop root); it
recreates that path's directories under the open folder, scope-checked level by
level, and writes the file under its original basename. A flat upload (no
`relpath`) is unchanged.

**Data — admin Sidebar pane vs operator `/data` route.** The
file/search UI is the headless `DataFileBrowser` component. On the **admin**
dashboard it is a Sidebar surface: a "Data" nav row (beside Artefacts/Sessions)
sets `activeNav='data'` and, via `onSelectData` → `AdminShell`'s `dataOpen`
state, renders `DataFileBrowser` (through `DataPaneSurface`) in the shell's main
column — chromed by the admin `HeaderMenu` + `Sidebar`, no full-page nav, no
remount. Off `/` the row navigates to `/?data=1`, hydrated on mount
(`hydrateDataFromUrl`); the pane is mutually exclusive with the WhatsApp
Transcript. The admin burger no longer carries a Data item. The **operator**
surface has no Sidebar, so the operator keeps the burger → `/data` full-screen
route described next; only the admin path changed.

**`/data` operator surface.** The `/data` route mounts the operator
`HeaderMenu` (logo-left / name-centre / burger-right; flyout = Chat / Data /
Log out) above the toolbar — the burger's Chat item returns to `/`, replacing
the former standalone Home button. The search bar flexes to fill its toolbar
row; the four file actions (Refresh, Upload, New folder and the list↔grid view
toggle) sit beside it as one uniformly styled icon group, collapsing under a
single `⋯` overflow menu at `≤640px`. File rows are two stacked full-width lines
(name, then `size · modified`) with no file-type icon, no hover download icon,
and no per-row delete button; a folder row keeps its glyph and navigates on tap,
a file row downloads on tap. **Long-press** a file row to enter multi-select:
every file row shows a selection indicator, the search bar becomes a Cancel
control, and a fixed bottom bar offers **Share / Download / Delete** over the
selection. Delete removes the non-protected files and keeps protected ones
(`N protected file(s) kept`); the listing carries a per-entry `protected` flag
from `isProtectedFromDeletion` so the UI never re-derives the rule. Share uses
the Web Share API and is disabled where unavailable. Signals:
`[data-ui] op=mount header=operator home=absent`, `op=select-enter`,
`op=select-exit reason=<cancel|action>`, `op=share supported=<bool> count=<n>`.

**The dashboard has a surface ramp and a display type tier.** Every page sits on
a page plane, cards rise onto a raised plane, and inputs and scroll regions
recede onto an inset plane. Each brand chooses how the card separates: SiteDesk's
card is a shade lighter than its page and casts no shadow, Real Agent's card and
page are both white and a shadow does the work. The page header is an inverted
band carrying the page title at the display size. Colours, radii, shadows and
type weights all come from the brand's own tokens; nothing is hardcoded, and a
token that resolves to nothing fails the build rather than painting invisibly.

**Folders open beside a rail on a wide screen.** At 1280px and above the folder
view splits in two: a list of the account's folders on the left, the files on
the right, with the folder you are in highlighted. Below that width the rail is
hidden and the breadcrumb does the same job.

**Status colours are shared.** Success, warning, error and info each carry a
solid fill, a pale tint and a text colour for that tint. Every pairing meets the
WCAG AA contrast minimum, on every brand.

**`/data` opens on the account's own folders, as cards.** With no `#path=` in
the URL the browser lands on **home**: the account's business folders (the
entity folders of its trade, plus documents, projects, contacts, the work it
has produced for you, anything a client has sent in, and any websites it
has published) shown
one per card, each listing the first 20 things inside it and a `+N more` row
past that. Tapping a card opens that folder in the ordinary list or grid. There
are never loose files at this level. Everything the platform and its plugins
keep for themselves — caches, logs, published sites, plugin workspaces, hidden
folders — sits behind a single **system** card at the end, and opens as the
ordinary list too. The split comes from the account's own `SCHEMA.md`, so it
follows the graph ontology rather than a hardcoded list, and nothing moves on
disk. Upload and New folder are disabled on the cards view because it names no
single folder to write into; open a folder first. The flat account root is still
reachable from the account name in the breadcrumb, which is the way back if a
folder is ever grouped wrongly. Signals: `[data-schema] op=classify` on every
account-root listing (`home=`, `system=`, `unknown=`, `status=ok|parse-failed`),
`op=home-files` when a name that should be a folder is a file, and
`op=card-children … status=error` when one card's folder could not be read.

**`/data` list ↔ grid view + image thumbnails.** A toolbar toggle switches the
file browser between the two-line list and a **grid**. In grid view, image
files render an inline thumbnail; folders render a folder tile (still navigate
on tap); every other file renders a labelled tile (name + size) that downloads
on tap. Tapping a thumbnail opens a full-size overlay (reusing the chat
attachment overlay), not a download — download stays reachable via long-press →
select → Download. The chosen view persists across reload and `/data` ↔ `/`
navigation (`localStorage['maxy-data-view']`). A file counts as an image only
when its **extension** is one the inline-serve route can serve
(`png/jpg/jpeg/gif/webp/svg`, the server's `detectMimeType` image set) — the
sidecar `mimeType` is not consulted, so the grid never promises a thumbnail the
serve route would refuse. Thumbnails load from the same `/api/admin/files/download`
route with `disposition=inline` (`inlineImageUrl`), which for an `image/*` file
returns the bytes with **no** `Content-Disposition` and a cacheable header so the
browser renders them in an `<img>`; a non-image inline request is refused `400`,
and the account-scope guard is identical to the attachment path. Signals:
`[data] op=view-mode mode=<grid|list>`, `[data] op=thumb-serve path=… mime=… size=… status=200`,
`[data] op=thumb-refused path=… reason=not-image`, and client `[data] op=thumb-error path=…`
from a thumbnail that fails to load.

**`/data` search — locate-in-files link and clear button.** Every
search hit is a `:FileArtifact` backed by a physical file, so each result card
whose `relativePath` is a non-empty string carries a **locate link** (folder
icon + containing folder; root-level files show `data`). Clicking it clears the
active search (query, results, error, chip) and navigates the file browser to
that folder via the same `path` state + `#path=` hash contract above, so the
file appears among its siblings and a reload restores the place. The link is a
sibling of the clickable card, so it never triggers the row's download. Hits
without a usable `relativePath` render no link; one
`console.warn('[data-search] op=locate-link-missing nodeId=…')` per result set
surfaces the index gap, and each click logs
`[data-search] op=locate nodeId=… folder=…`. The search bar shows an **× clear
button** while it holds text — clicking empties the input, clears results
(restoring the file-browser body), and refocuses the input.

### Browser / device-browser

| Mount | Purpose |
|---|---|
| `/browser` | `POST /launch` — launches Chromium on the resolved transport (native display or VNC) on demand. Backs the standalone `/browser` operator page. Lifeline tag `[admin/browser/launch]`. |

The in-chat / artefact-embedded brand-VNC iframe components and the
`/browser-iframe` event-beacon route were retired and stay
deleted; the `/device-browser` (navigate-the-device-tab) route was also
removed and is not restored. The standalone `/browser` page is a
sanctioned operator surface peer to `/graph` and `/data`: it embeds the live
VNC viewer (`/vnc-viewer.html`, popout `/vnc-popout.html`) and calls
`/api/admin/browser/launch`. On darwin (no noVNC asset) it shows a "VNC surface
not available" state.

### Diagnostics

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

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

## System-stats widget

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

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

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

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

**Thresholds.**

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

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

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

**Diagnostic grep.**

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

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

## Activity view

Source:
[`server/routes/admin/activity.ts`](../../../ui/server/routes/admin/activity.ts)
(house-gated proxy) and
[`platform/ui/app/activity/page.tsx`](../../../ui/app/activity/page.tsx)
(page). CSS lives under `.activity*` in
[`platform/ui/app/globals.css`](../../../ui/app/globals.css).

**What it shows.** `/activity` lists every live session across every account
in the install, one row per session: account, title, model, status, age, idle,
turn count, RSS, CPU %, cumulative tokens, and imputed GBP cost. Columns sort
on click. The sidebar session list is scoped to the caller's own account, so
this is the only surface that answers "where is the activity" install-wide.

**House account only.** The manager binds loopback and gates nothing, so the
scoping lives on the admin route. The house is resolved by the same three-arm
predicate the spawn path uses (`resolveAccountDir`): exactly one `role:"house"`
account, else a sole account declaring no role, else deny — two houses is
corruption and denies rather than picking one. Both operands are null-checked,
because `caller === house` alone is true for two nulls. A caller pinned
elsewhere receives 403 and a logged `op=gate decision=deny` carrying its
reason. The `HeaderMenu` item is admin-variant only for the same reason.

Be precise about what this is. Under the install-wide access model every
authenticated install admin can re-pin to any account via
`POST /api/admin/session/switch-account`, so the gate is a scope selector, not
an authority boundary between privilege levels.

**Where the numbers come from.** The manager assembles them at its own
`GET /activity`, joining the live PTY set, the watcher's per-row `accountId`,
the JSONL enumerator (turns, model, tokens, last message), the PTY census
(RSS and CPU), and a bounded metering fan-out. The admin route adds the one
fact the manager cannot see: the `data/accounts` directory count, taken
independently of `listValidAccounts()` so a rejected account shows as
`onDisk > valid` rather than silently shortening the list.

**Reading the cells.** A dash means unmeasured, never zero — an unmeasured CPU
is not an idle session. Cost is the price arm matching the row's own model,
because the metering core prices one token count under both Opus and Sonnet
assumptions without knowing which ran; a model in neither family shows a dash.
Every dashed cost carries its reason as a tooltip, so an unpriced model, a
failed parse and a session with no transcript are distinguishable rather than
three readings of the same glyph.
A session whose sidecar carries no account shows `untagged` rather than being
folded into the house. The census age is rendered because the snapshot is a 60s
sample and a dead collector emits nothing.

## Sidebar surfaces

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

> **Redesigned chrome.** The admin shell was rebuilt so the sidebar
> owns all navigation; the full-width top header bar, the burger flyout, and the
> "Powered by Claude Code" footer strip are retired. After the redesign:
>
> - **Nav rows** are Dashboard, Chat, Tasks, Calendar, Routines (the automation
>   `:Event` nodes at `/routines`, `Zap` icon), Data, the brand container
>   row (Projects / Properties / Jobs, `Folder` icon, opening the graph filtered
>   to the brand's primary container), Graph (the unfiltered whole graph at
>   `/graph`, waypoints icon — active on plain `/graph`; the container row is
>   active only when a `label=` filter is present), Activity, and Browser. The
>   active row carries an accent left indicator. The older People / Agents / Artefacts entries in the
>   table below are pre-redesign.
> - **Dashboard home** renders on `/` (a greeting, stat cards, a Live-now column,
>   a Recent artefacts card, and a Channels column), consuming existing endpoints
>   only.
> - **Per-surface header.** Each surface carries its own header (`PageHeader`):
>   the sidebar collapse toggle, the page title, and a Claude status pill on the
>   right (Connected / Connect, driven by `/api/health`). The pill replaces the
>   retired burger Disconnect visibility and footer strip.
> - **Account kebab.** The version / Connect-Disconnect / Change PIN / Log out
>   items moved from the burger onto a kebab on the sidebar user footer
>   (`AccountMenu`, extracted from the old `HeaderMenu`; the operator variant
>   still mounts `AccountMenu` for its reduced set).
> - **Sub-account switcher on the brand head.** The head at the top of the sidebar
>   splits into two controls when the install has more than one sub-account: the
>   brand icon jumps straight to the house account, and the name plus chevron
>   opens the `SubAccountPicker` directly beneath the head (an outside click or a
>   second click on the name closes it). Clicking the icon while already on the
>   house account does nothing. A single-account install shows the same head as a
>   plain, non-interactive lockup, and the collapsed rail hides both controls. The
>   switcher used to sit in the account kebab; it no longer does. The switch
>   navigation itself (`useSubAccountSwitcher`, dropping the `?session=` param) is
>   unchanged.
> - **Mobile (< 720px).** The sidebar opens as a drawer from a 44x44 header menu
>   button, and a fixed bottom tab bar (Home, Chat, Tasks, Data, Calendar) sits
>   under the content; Graph, Activity, and Browser stay reachable through the
>   drawer. Between 721 and 1080px the sidebar defaults to the rail. The
>   drawer's SESSIONS head collapses its New-session pill to an icon-only `+`
>   (34px square); the label returns above 720px. The **operator** variant gets
>   its own fixed bottom tab bar — Chat, Conversations (rendered only when the
>   pane handler is wired), Data — while its burger keeps the AccountMenu tail.
>   In the webchat composer the icon control bar moves **above** the input box
>   with 44px touch squares (desktop keeps the below-input row), and the
>   composer placeholder never wraps (single line, ellipsized).

> **Admin variant only.** Every surface below belongs to the `admin` shell
> variant. The **operator** variant (`operator.<host>`) mounts no sidebar at
> all: its dashboard is the persistent admin webchat at `/` and the data
> browser at `/data`, plus a **Conversations** item reached from a dynamic
> burger item — header order Chat → Conversations → Data → Log out, in a
> single-column `.platform.platform-operator` grid. Below 720px the same three
> nav items also render as the operator bottom tab bar (see Mobile above);
> the Chat tab targets the operator root `/`, the canonical-session route. On the operator **chat**
> surface the Conversations item opens the same in-chat session
> switcher the admin `/chat` surface uses (`ChatSurface`'s `SessionList` flyout):
> the operator's own admin webchat conversations, newest-first, each resuming
> live via `/chat?session=<id>`, with rename / archive / delete / end / new — not
> a read-only reader. The item gates on the flyout handler being wired, exactly
> like the admin and lite items. The read-only inbound-channel **Conversations**
> reader (WhatsApp, Telegram, public webchat, via the admin-gated
> `/api/whatsapp-reader/conversations` route) remains on `/data`'s Conversations
> item and in the admin Sidebar; it is no longer reachable from the operator chat
> surface. The operator surface is documented in full in the internal doc
> `maxy-code/.docs/operator-dashboard.md`.
>
> **Operator `/chat`.** The reduced operator chrome renders on
> *every* operator chat surface, not just root `/`: the `/chat` server handler
> serves `operator.html` for an operator host (`chat.html` only for admin
> hosts), so `/chat` and `/chat?session=<id>` mount the same single-column
> operator shell. Unlike the operator root — which forces the canonical session
> — operator `/chat` honors `?session=`, opening a specific session inside the
> reduced shell. Admin-host `/chat` is unchanged (full developer shell).
> **Sub-account switch resets the session target.** Because `/chat` honors
> `?session=`, the header sub-account switcher (`useSubAccountSwitcher`) drops
> the `session` query param when it navigates after a switch, rather than
> reloading verbatim. A verbatim reload would keep the *previous* account's
> `?session=<id>`, which the reloaded surface re-pins — stranding the operator
> on the wrong account's session . Dropping the param lands on the
> account-scoped canonical surface, so `resolveCanonical` resolves the switched
> account's latest owned session, or the new-session splash when it has none.
> The pathname is preserved (a switch on `/chat` stays on `/chat`); the operator
> root `/` and bare `/chat` were always correct (they force-ignore / carry no
> `?session=`).
>
> **Width.** The operator header, the operator chat column, and the `/data`
> header+toolbar+body all render in one centred column bounded to
> `--chat-measure` (48rem). That token is a `:root` design token (beside
> `--measure`/`--gutter`); the operator-header clamp
> (`.admin-shell-root:has(> .platform-operator) > .admin-header`), the chat-column
> clamp (`.platform-operator > .webchat-page`), and the `/data` clamp
> (`.data-main`) all read it, so logo and burger align to the panel edges on both
> surfaces. In the full admin shell the one `--chat-measure` consumer is the
> main-column WhatsApp/Telegram viewer (`.platform > .wa-reader`), clamped and
> centred so its wallpaper reads as a bounded column, not an edge-to-edge
> surface; the rest of the admin shell stays edge-to-edge. The public webchat
> (`.public-surface`) inherits the same value.
>
> **Conversations item.** The dynamic Conversations burger item gates on the
> host wiring its open-handler (like the admin and lite items), not on a channel
> count. On the operator **chat** surface the handler is always wired, so the
> item is always present and opens the live session switcher (above). On `/data`
> the handler is wired only when ≥1 reviewable inbound conversation exists
> (fetched from `/whatsapp-reader/conversations` with the operator's session
> key), and selecting it renders the read-only reader in place (the file browser
> is swapped for it; Back returns) — so `/data` keeps its hide-when-empty
> behaviour. The flyout-open signal `[operator-ui] op=conversations-menu
> present=<bool>` fires for the operator variant on both surfaces; `present=false`
> while the operator expects the item is the handler-not-wired signature.

| Surface | What it does | Backed by |
|---|---|---|
| `+ New session` button | Opens `NewSessionModal`, which POSTs `{channel, permissionMode, model, initialMessage}` to `/api/admin/claude-sessions`. | `claude-sessions.ts` |
| Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
| Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so Real Agent reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
| Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/whatsapp-reader/conversations` |
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
| Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
| Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) 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. | `sidebar-artefacts.ts`, `files.ts` |
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
| Footer | Operator avatar, name, role, and the actions popover. | `session.ts` |

## Artefact pane

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

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

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

## Health vs version

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

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

## Installable PWA

The admin (`index.html`), operator (`operator.html`), and chat (`chat.html`)
shells are installable. Each links its own webmanifest (`/manifest-admin`,
`/manifest-operator`, `/manifest-chat`) whose `icons` declare an explicit
`192x192` and `512x512` `any` entry plus a `512x512` `maskable` entry, and an
`apple-touch-icon`. These all point at an **opaque, padded** app icon baked on a
white tile — never the transparent brand mark, because iOS/macOS composite a
transparent app icon onto black. The opaque icons ship per brand
(`assets.appIcon` / `appIcon192` / `maskableIcon`); `manifest`
`background_color` only paints the splash screen, so the tile background is
baked into the PNG. The maskable variant carries a wider safe zone so Android's
circular crop never clips the mark.

The sign-in page is installable too. The remote-auth gate redirects every
unauthenticated request to it, so a fresh device installs from there; the page
links the admin manifest and the opaque `apple-touch-icon` so the install adopts
the brand name and icon instead of a monogram generated from the title. The
sign-in page registers no service worker — only the authenticated shells do.

A boot census logs `[pwa-census] surfaces=3 manifest-linked=3 icons-present=3`
when every shell links its manifest and the app-icon files are present; a missing
or mis-named icon asset shows `icons-present<3`, and a dropped head injection
shows `manifest-linked<3`. The graph, data, and browser shells are intentionally
non-installable.

## WhatsApp socket resolution

Every route that needs a live WhatsApp socket resolves it through
`resolveSocket` in
[`app/lib/whatsapp/manager.ts`](../../../ui/app/lib/whatsapp/manager.ts),
never through the raw `getSocket` primitive it is built on. `getSocket` is
an exact-key lookup, and a connection is keyed by its credential dirname
(often a legacy `default`) while callers frequently hold the platform
account UUID. A raw lookup therefore returns `null` for a socket that is
alive under the other key.

`resolveSocket` accepts either key and splits that `null` into the two
failures that demand **opposite** operator advice:

| Reason | What it means | Correct advice |
|---|---|---|
| `key-miss` | No connection is registered under this id. The socket is likely alive under a different key. | Fix the account keying. **Never advise re-pairing** — that would re-pair a healthy socket. |
| `disconnected` | The connection exists and its transport is genuinely down. | Transport-down; reconnecting is appropriate. |

The reason label, the `presentKeys` rendering, and both operator-facing
messages have one definition, in
[`app/lib/whatsapp/socket-resolution-error.ts`](../../../ui/app/lib/whatsapp/socket-resolution-error.ts).
Callers keep their own log shape, HTTP status, and throw-vs-return
handling, but never hand-write the message strings — a copied key-miss
message is how the "do not advise re-pairing" instruction drifts out of
one branch unnoticed.

Two callers predate the shared formatter and still carry their own
equivalent wording: the reader reply path and the `/send-admin` route.
Consolidating them is tracked separately; do not copy their strings into
new callers.

## Related references

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

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

# Graph View

The **Graph** admin page (`/graph`) renders a force-directed view of your
account's Neo4j subgraph. Node captions are hidden on first load so the
whole-graph overview stays uncluttered, and appear the moment you interact.
Once shown, captions follow the zoom level so you see the most useful identity
at every scale.

## Default view

Opening `/graph` with no filter chips selected shows **all your top-level
nodes** straight away, so the canvas is populated on arrival rather than
blank. Top-level nodes are the operator-meaningful entry points (People,
Organizations, Tasks, Jobs, and the rest of the filter chip roster); their
child and infrastructural nodes stay off this view and are reached by
clicking into a node's neighbourhood. Conversations are deliberately not
top-level — see **Conversations are off the default canvas** below.

This view is capped at **1000 nodes**. When your graph has more than that, the
canvas shows 1000 of them and a note reads, for example, `Showing 1,000 of
3,412 top-level nodes`, so you know some are not on screen. When the count is
under the cap the note reads `Showing 312 of 312 top-level nodes`. Selecting
one or more filter chips switches to the chip-filtered view, which returns
every matching node and asks you to narrow if a single label set exceeds its
own larger limit.

## Sales-progression nodes

For estate-agent accounts, a sale-agreed deal is held in the graph as a `Sale`
(the agreed transaction), its `Chain` (the ordered links of connected sales),
and a `ChainLink` per property in the chain (carrying the ten milestone dates
and the seller, buyer, and solicitor contacts). These are the source of truth
the negotiator's chase-progression and progression-inbox workflows read and
update — they replace the old free-text progression notes. Like conversations,
they are not top-level chips; you reach a chain by searching for its address or
a party name and pivoting into its neighbourhood.

## Search and pivot

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

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

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

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

## Full-screen the canvas

The toolbar carries a **maximize** button (the expand icon, beside Refresh).
Clicking it lifts the toolbar and canvas out of the page and paints them over
the whole browser window, hiding the sidebar and header so a dense
neighbourhood gets the full screen. The button turns into a **minimize** icon
while maximized. Press **Escape** or click minimize to return to the boxed
view. When the filter popover is open, the first Escape closes the popover and
the next exits full screen, so one keypress never does both, and the minimize
button is always on screen so you can never get stuck.

This is an in-page overlay, so the browser tab and address bar stay put. It is
not the operating system's full-screen mode. The maximize button shows only on
the `/graph` page, not where the graph is embedded in another pane.

## Captions hidden on first load

When you open `/graph` (or refresh the page) the whole graph auto-fits into the
viewport, which on a busy graph would render every caption at once and overlap
them into unreadable clutter. So captions start hidden. They appear on your
first interaction of any kind — zoom, drag, a filter chip, a search, clicking a
node — and stay visible for the rest of that visit. To get the clean
caption-free overview back, reload the page.

This is a one-off marker tied to page load, not a zoom threshold: after the
first interaction, captions follow the zoom tiers below and do not hide again
until the next reload.

## Conversation label tiers

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

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

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

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

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

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

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

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

## Tooltips and side panel

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

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

## Deleting a node

Two surfaces, same outcome:

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

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

## Mobile layout

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

## Trashed conversations

Conversations are off the default canvas (see below), so you meet trashed
ones the same way you meet live ones — via search or drilldown. Trashed
nodes are hidden by default there too; toggle **Show trashed** in the filter
popover to surface them. They render with a faded fill and dashed border,
with their zoom-tier labels intact. The `N msgs` count excludes trashed
Messages, so the detailed-tier label reflects only live turns in the
conversation.

## Conversations are off the default canvas

Conversations no longer appear as top-level nodes or filter chips. Both
admin conversations (your own chats with the agent) and public
conversations (the agent's chats with customers) are chat transcripts, not
operator-entry domain entities, so they stay off the populated-on-arrival
canvas and out of the filter popover.

They remain fully in the graph and fully searchable. You reach a
conversation two ways:

- **Search** — type a term and the conversation (or a message inside it)
  surfaces as a search hit; clicking it pivots into the cluster. Search
  reaches the body text of every message, so a rare word like "ATM"
  returns every conversation that mentions it, not just ones with that
  word in the title.
- **Drilldown** — clicking any rendered node next to a conversation, or
  the conversation via a search hit, pulls the whole conversation cluster
  onto the canvas (see **Cluster-expand on Conversation/Message clicks**).

Because conversations carry no chip, the per-chip Channel (Web / WhatsApp)
and Message-kind (User / Assistant / WhatsApp) sub-facet filters are no
longer surfaced. The underlying channel and message-sublabel values are
unchanged on the nodes; only the chip-driven filter UI is retired.

## Sidebar conversations list

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

## Agents in the graph

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

## Agent-execution telemetry

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

## Direct edge management

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

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

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

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

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

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

# Neo4j edge types — operator reference

How Real Agent's graph wires itself.

## Typed-edge auto-extraction

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

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

## Typed-edge allowlist

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

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

| Source label | Edge type | Target label |
|---|---|---|
| Person | ATTENDED | Event |
| Person | ATTENDED | Meeting |
| Person | WORKS_AT | Organization |
| Person | WORKS_AT | LocalBusiness |
| Person | INVESTED_IN | Organization |
| Person | INVESTED_IN | LocalBusiness |
| Organization | INVESTED_IN | Organization |
| Organization | INVESTED_IN | LocalBusiness |
| Person | FOUNDED | Organization |
| Person | FOUNDED | LocalBusiness |
| Person | ADVISES | Organization |
| Person | ADVISES | LocalBusiness |
| Person | ADVISES | Person |
| Message | MENTIONS | Person |
| Message | MENTIONS | Organization |
| Message | MENTIONS | LocalBusiness |
| Message | MENTIONS | Event |
| Page | MENTIONS | Person |
| Page | MENTIONS | Organization |
| Page | MENTIONS | LocalBusiness |
| Page | MENTIONS | Event |
| Meeting | MENTIONS | Person |
| Meeting | MENTIONS | Organization |
| KnowledgeDocument | MENTIONS | Person |
| KnowledgeDocument | MENTIONS | Organization |
| ConversationArchive | MENTIONS | Person |
| ConversationArchive | MENTIONS | Organization |
| Note | MENTIONS | Person |
| Note | MENTIONS | Organization |
| Idea | MENTIONS | Person |
| Idea | MENTIONS | Organization |
| Post | MENTIONS | Person |
| Post | MENTIONS | Organization |
| Report | MENTIONS | Person |
| Report | MENTIONS | Organization |
| Person | AUTHORED | Post |
| Person | AUTHORED | Report |
| Person | AUTHORED | Page |
| Person | AUTHORED | Note |
| Person | AUTHORED | KnowledgeDocument |
| KnowledgeDocument | ATTACHED_TO | Meeting |
| Page | ATTACHED_TO | Project |
| Note | ATTACHED_TO | Project |
| Page | REFERENCES | Page |
| Report | REFERENCES | Report |
| Report | REFERENCES | Page |
| Person | REPORTS_TO | Person |
| Source | AUTHORED_BY | Person |
| Project | ADVANCES | Objective |
| Project | ADVANCES | KeyResult |
| Task | ADVANCES | Objective |
| Task | ADVANCES | KeyResult |
| Task | DEPENDS_ON | Task |

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

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

# Platform Internals — Retrieval Architecture

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

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

---

## Retrieval Pipeline Overview

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

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

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

Each enhancement is independently flagged. All flags default OFF — the unflagged pipeline is identical to the baseline weighted-sum + nodeId-only-dedup behaviour. Typed-edge backlinks and the compiledTruth property have landed, so the boost data is populated; flag activation, soak windows, and per-flag measurement remain pending.

### Hybrid Search Detail

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

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

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

### Enhancements (flagged, default off)

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

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

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

### What the hybrid approach catches

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

---

## Embedding Infrastructure

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

### Indexed node labels

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

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

### Full-text index

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

### Embedding lifecycle

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

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

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

`embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits a multi-input array into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. Each POST is sized from the device's *measured* throughput, not a fixed constant: the first POST of a multi-input batch is a single-input probe (the only size provably safe without a measurement), and every POST's observed per-character wall-time folds into a running maximum. Subsequent POSTs are packed with as many inputs as fit a character budget `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / observedMsPerChar)`, capped by the `EMBED_SUBBATCH_SIZE` count ceiling. Sizing by characters rather than input count matters because an embed's wall-time scales with its characters — a POST sized by count from a short probe (a document summary) would over-provision when the later inputs are near-cap sections and abort. So a CPU-only device slower than the old budget gets character-bounded POSTs that complete inside `EMBED_REQUEST_TIMEOUT_MS`, instead of a fixed 16/32-input POST that exceeds it and aborts the atomic ingest write; the running maximum is monotonic, so a mid-batch slowdown shrinks the remainder and never re-expands into a timeout. `EMBED_SUBBATCH_SIZE` (default 4) is the thermal count ceiling and the hard cap on any POST — kept low because the character budget is derived from the probe's observed rate, so a probe taken while the machine is briefly quiet would otherwise let the next POST grow to the ceiling and then blow `EMBED_REQUEST_TIMEOUT_MS` if desktop contention spikes mid-batch; a ceiling of 4 bounds that worst case (~4 near-cap cold-CPU embeds) under the timeout on a contended shared desktop, while a quiet appliance is governed by the character budget instead. `EMBED_MS_PER_INPUT` (default 6000 ms, env-tunable) no longer sizes the POST; it is the reconciliation baseline — after each POST an `[embed] op=drift` line warns when the observed per-input time exceeds the budget by more than 1.5×, so a device slower than budget is surfaced by a standing check rather than by a later ingest failure. Every `/api/embed` POST also carries `keep_alive` (`EMBED_KEEP_ALIVE`, default `-1` = keep the model resident forever) so an interactive graph query-embed never cold-loads and blows its 5 s timeout. All knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.

---

## Knowledge Document Hierarchy

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

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

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

### Semantic chunking

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

---

## Response-side `fields` projection

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

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

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

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

## Guard Layers

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

### Layer 1: Soft-delete filter

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

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

### Layer 2: Scope filter

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

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

### Layer 3: Per-agent tag filter

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

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

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

### Layer 4: Graph expansion enforcement

Related nodes discovered during hop traversal are independently filtered:

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

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

### Layer 5: Account isolation

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

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

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

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

---

## Query Classification

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

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

### Classification output

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

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

### Knowledge retrieval gate

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

### The canonical public-agent locator

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

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

### Observability

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

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

---
---

## Reports — durable workflow output

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

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

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

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

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

---

## Graph Expansion

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

### Traversal mechanics

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

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

### What expansion provides

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

---

## Keyword Subscriptions — Reactive Per-Agent Knowledge

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

### Dual search per keyword

For each subscription keyword, two complementary searches run:

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

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

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

### Union semantics

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

### Lifecycle

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

---

## Conversation Search

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

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

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

### When conversations are created

`:Conversation` nodes on webchat (admin login, "New conversation" in the burger, a new public visitor) are created lazily. Opening the chat or logging in does not write anything to the graph — Real Agent 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. That forensic row is addressed by cacheKey (`default:<phone>`) on the socket-owning account and deliberately has no session behind it. A WhatsApp sender therefore carries a **second** `:Conversation`: the anchored row written at spawn on the account the session actually runs under, keyed by the session's own deterministic id, which is the row every `MATCH (c:Conversation {sessionId: $SESSION_ID})` resolves. Two rows, two jobs, not duplicates. See `.docs/web-chat.md` "Deferred conversation persistence", `.docs/whatsapp.md` "Session continuity", and `.docs/session-conversation-identity.md` for the per-channel identity contract.

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

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

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

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

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

**Deterministic admin-hostname surface.** The admin shell — `/calendar` and every other authenticated route — is served only on the admin host, never the public host (`/calendar` returns `404 Not found` on a public host). The `mcp__plugin_admin_admin__admin-hostname` MCP tool is the admin-host twin of `public-hostname`: it resolves the host an operator should be pointed at for an admin-surface URL. It reads `<configDir>/cloudflared/config.yml` ingress, keeps only rules whose `service:` is an `http://` URL (so `ssh://`/`tcp://` off-LAN rows are excluded), and returns the first such hostname that is neither public (`public.` prefix or a `<configDir>/alias-domains.json` entry) nor operator (a `<configDir>/operator-domains.json` entry) — exactly the admin class of `classifyHost()` in `platform/ui/server/public-host-gate.ts`. Returns `{hostname, isApex, source:"cloudflared-config.yml"}` on hit, or `{hostname:null, source:null, reason:"no-admin-host"}` on miss. The admin host is often a different base domain from the public host (public `maxy.chat`, admin `admin.maxy.bot`), so it can never be string-derived from the public one — the `calendar-site` skill calls this tool to surface the admin `/calendar` link instead of guessing.

### Cross-tab session rotation

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

---

## Context Assembly — How Retrieved Knowledge Reaches the Agent

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

### Channel spawn routing by role

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

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

### Public agent paths

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

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

### KNOWLEDGE.md staleness guard

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

### Admin agent path

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

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

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

### Public-agent memory context

Public agents historically had their memory context injected by a server-side bridge
(`fetchMemoryContext`) that spawned the memory MCP server with `ALLOWED_SCOPES=public,shared`
and ran a `memory-search` on the agent's behalf. That bridge was removed: public agents
are toolless (`buildPublicDeny` denies `mcp__memory__*`), so neither the agent nor a
server-side proxy runs a public-scope memory search, and the public-scope fulltext index that
path ranked against was retired alongside it.

A gated visitor's per-visitor memory still reaches their next conversation, without any tool
and without reopening the ringfence. The session-end reviewer writes per-visitor slice nodes
(`{sliceToken, scope:'user'}`); at the next gated spawn the UI process fetches that visitor's
own `scope:'user'` slice nodes (a slice-pinned read, not a public-scope search), renders them
to a `<previous-context>` block, and threads it through the public spawn body so the manager
appends it to the agent's system prompt. Only the visitor's own slice is read; open-mode
visitors, admins, and first visits inject nothing.

### Standing-rules reconciliation and per-turn injection

Standing rules the operator gives over a channel are `Preference` nodes. Two mechanisms keep them effective across a long-lived session that auto-compacts.

**Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.

**Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.

**Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.

**Carry what a reply points at (WhatsApp reply quote).** A WhatsApp sender replying to an earlier message produces a quote that `app/lib/whatsapp/inbound/extract.ts` resolves to `{ id, text?, sender? }` and `manager.ts` assigns to the route payload's `replyContext`. That field had no reader: the `server/index.ts` gateway argument omitted it, `AdminInbound` and the hub frame had no such field, and the channel service's `composePayload` emitted the caption and media notes only, so a message saying "prepare an email confirmation for this booking" reached the agent with nothing to resolve "this booking" against. It now rides both WhatsApp admin doors. On the persistent door it travels `server/index.ts` -> `AdminInbound` -> the hub frame the SSE route stringifies -> the channel service's `InboundPayload`, where `composeReplyNote` renders it as a bracketed note at the top of the payload, inside `## Context` and above the caption and any woven media notes: the quoted body is sender-controlled and belongs in the reframed untrusted zone, never in the platform preamble. Both channel-server inbound paths carry it, through the same `doorInboundPayload` seam that carries `standingRules` and the schedule provenance marker: the single-target SSE loop parses the whole frame, and the unified multi-target door reader rebuilds the payload field by field, so a field that seam omits is dropped on multi-target sessions alone. That seam is guarded by a test which reads the `InboundPayload` declaration and the rebuild function rather than a fixture, because the pre-existing byte-identity guard compared one hardcoded payload and so stayed green for every field added after it was written. On the passive account-manager door there is no channel server and no `## Context` frame, so the one-shot's whole initial prompt is read at face value; `composePassivePrompt` therefore puts the BARE sentence above the requirement and never the quoted body. The body arrives from the sender's own client and is never checked against the message store, so quoting it in a platform-voiced bracket on the door with no untrusted zone would let a modified client put words the agent never said into a `work-create` filing. The empty-text drop gate still tests the requirement alone, so a quote never rescues an inbound carrying no requirement of its own. The quoted body is capped at `QUOTED_TEXT_CAP` (500 characters, `…` appended when cut) because it is unbounded sender-controlled input, and the cut lands on whole characters so a body whose 500th boundary falls inside a surrogate pair cannot emit a lone surrogate at a position the sender chose. Only the channel service renders a body, so the cap lives there alone; platform/ui's `app/lib/whatsapp/reply-quote.ts` holds just the bare sentence for the passive door, and both packages hardcode that sentence in their own tests so the two doors cannot drift apart on the wording (the packages never cross-import, the same arrangement `REFRAME_INSTRUCTION` and `MediaRef` use). Telegram and webchat extract no quote at all, so neither carries one. `payloadBytes` measures the whole inner payload, so a reply carrying a quote produces a visibly larger `[reframe] op=applied … payloadBytes=<n>` than the same text without one, and a bare-text-length `payloadBytes` on an inbound whose store row has a non-null `quotedId` is the signature of this defect returning. Because the field is optional at every hop, dropping it from either `server/index.ts` argument would be silent (tsc stays green, and no unit test executes that wiring file), so the `check-reply-context-threaded` build gate asserts both argument sites.

**Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-inject] op=reconcile accountId=… stored=… injected=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose stored active count diverges from what injection surfaces; injection now surfaces every stored active preference verbatim, so `stored == injected` is the healthy signal and any gap is a dropped-row regression (a reintroduced cap, filter, or truncation). A clean pass logs `op=reconcile drifted=0`, the no-event detector.

**Consult-before-send gate.** Injection (above) puts the owner's active rules in front of every channel turn, but the growing layer-2 `Preference` set is read on demand with `profile-read`, a per-turn judgement the agent can skip, the miss that dropped a customer signature on a delivered document. `hooks/preference-consult-gate.sh` (admin plugin) is the deliverable-boundary enforcement: a PreToolUse gate on customer-facing document deliverables (`browser-pdf-save`/`SendUserFile` to `memory/users/<phone>/documents/`, and email/Outlook sends carrying attachments) that blocks (exit 2) when no `profile-read` ran after the last user message in the transcript. It fires on documents only, never on casual replies. **Observability:** `[preference-gate] op=allow tool=<name> consulted=true` on an allowed document deliverable, `op=bypass tool=<name> detail=no profile-read this turn` on a block; a stream of `op=bypass` is the un-consulted-send signature. The advisory companion `hooks/preference-consult-directive.sh` injects the two-layer architecture reminder every turn (`[pref-wrapper] op=inject`). The placement half, promoting finished deliverables out of `output/` scratch, is counted by the `data-manager` reconcile audit (`scratch-refs`, `stranded`); making that audit run on a standing periodic cadence is a filed follow-up. The inbound mirror is the `uploads/` intake inbox: an uploaded file lands at `uploads/<attachmentId>/` and is promoted to its canonical entity bucket when its graph node is created, so a persisted node reference resolving into `uploads/` is intake drift, counted by the same reconcile audit as `stranded-intake`. Unlike `output/`, `uploads/` is never rebuilt by a tool, so the `data-manager` may promote a file out of it. Beyond placement and intake, the same audit counts intra-folder sprawl inside each operator-data entity folder from disk structure alone: superseded version copies (`<base>.ext` and `<base>-vN.ext` are one deliverable, all but the newest counted `versioned`), format twins (a source `.html`/`.md` render kept beside its delivered `.pdf` of the same basename, counted `format-twins`), and subfolders nested under `projects/<name>/` or `contacts/<name>/` that the flat rule forbids (counted `stray-subdir`). These are read-only counts; removal or flattening happens only on a `data-manager` stewardship dispatch, each paired with a `database-operator` reference update. The reconcile output contract is `unreachable=N broken-refs=M scratch-refs=P stranded=Q stranded-intake=R versioned=S format-twins=T stray-subdir=U`.

---

## Output Formatting and Budget

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

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

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

---

## Index Discovery and Schema Evolution

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

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

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

---

## Inbound Message Gateway

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

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

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

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

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

### Diagnostics

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

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

---

## Tool Eagerness — eager-load vs deferred

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

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

1. **In-process plugins.** Every admin-eager tool is registered via `eagerTool(server, name, description, inputSchema, handler)` from `platform/lib/mcp-eager/` instead of `server.tool(...)`. The helper calls `server.registerTool` with the `_meta` flag set. Both `eagerTool` and the non-eager `lifelineTool` (from `platform/lib/mcp-lifeline/`) wrap the handler with `wrapWithLifeline`, which emits a structured `op=request`/`op=result` log pair per call including a holdings census — see `.docs/mcp-helper-observability.md` "Tool-call lifeline".
2. **Upstream graph proxy.** The upstream Python `mcp-neo4j-cypher` server has no `_meta` channel, so `platform/lib/graph-mcp/src/index.ts` intercepts every `tools/list` response on the wire and injects `_meta["anthropic/alwaysLoad"]: true` into each tool entry. The `[graph-mcp] tools/list eager-flagged count=<N>` stderr line confirms the injection fired.

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

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

## Spawn-time MCP and subagent registration

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

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

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

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

**Whether a plugin's server mounts is decided by the generated manifest, not the `mcp:` block.** A plugin ships its MCP as `mcp/`; the manifest generator ([`scripts/generate-plugin-manifests.mjs`](../../../../scripts/generate-plugin-manifests.mjs), run at bundle time over the payload) reads the plugin's `mcp-manifest:` frontmatter. `auto` emits an `mcpServers` block into the generated `.claude-plugin/plugin.json` — routed through `lib/mcp-spawn-tee`, carrying no per-session env (`LOG_DIR`/`SESSION_ID`/`ACCOUNT_ID`/`NEO4J_*`/`PLATFORM_ROOT` ride the PTY env block described above) — and copies the tee into the plugin's `lib/`; `skip` emits nothing, so Claude Code never spawns the server. The generator recognises two server shapes. A plugin-local `mcp/` (its `hasMcp` check: `mcp/dist/index.js` or `mcp/package.json`) uses `auto` or `skip`. A tool-plugin whose `mcp:` server is a shared `platform/lib/<name>` binary rather than a plugin-local `mcp/` (e.g. `graph`, served by `platform/lib/graph-mcp`) is annotated `mcp-manifest: shared-lib`: the generator emits that plugin's `mcp:` block verbatim into `mcpServers` — command, args (anchored on `${PLATFORM_ROOT}`, routed through the shared `lib/mcp-spawn-tee`), and env — which Claude Code expands at spawn from the PTY env block. No tee copy is made into the plugin, because both the tee and the server resolve from `${PLATFORM_ROOT}` outside the per-plugin cache. `graph` is the one shared-lib tool-plugin; every other tool-plugin ships a plugin-local `mcp/` and uses `auto`. A plugin with an `mcp:` block, no plugin-local `mcp/`, and no `mcp-manifest: shared-lib` annotation is a FATAL at generation time. Premium plugins import platform libs (`mcp-lifeline`, etc.) via a source-tree relative path; the bundler's premium-lib shipper (`packages/create-maxy-code/scripts/bundle.js`) rewrites that path and vendors each referenced `platform/lib/<name>` into `premium-plugins/<bundle>/lib/<name>/`, so the auto-mounted dist resolves its imports. The `mcp-allowlist-without-server` gate above proves only that a plugin's PLUGIN.md `mcp:` block matches its allowlist — it reads source, not the generated manifest — so a plugin left on `skip` passes that gate while its server silently never mounts (its tools are listed but not live). The `tool-registration` gate below closes that blind spot.

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), 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.

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

**Brand-foreign premium bundles.** This 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.

**Premium sub-plugin Claude Code registration.** A premium bundle takes one of two shapes on disk. A **standalone** bundle (`premium-plugins/<bundle>/` with a `PLUGIN.md` and no `plugins/` subdir — `teaching`, `venture-studio`, `writer-craft`) is a single plugin under the `maxy-premium` marketplace. A **nested-marketplace** bundle (`premium-plugins/<bundle>/plugins/<sub>/` — `real-agent`, `sitedesk`) carries only bundle-level agents at its root; its skills live in sub-plugins, each a full plugin under a per-bundle marketplace named `maxy-premium-<bundle>`. The bundler ([`scripts/generate-plugin-manifests.mjs`](../../../../scripts/generate-plugin-manifests.mjs)) stamps a `.claude-plugin/marketplace.json` at `platform/plugins/` (`maxy-platform`), at each nested bundle's `plugins/` dir (`maxy-premium-<bundle>`), and at the premium root (`maxy-premium`). The installer ([`registerLocalAndExternalPlugins` in `packages/create-maxy-code/src/index.ts`](../../../../packages/create-maxy-code/src/index.ts)) discovers **every** `premium-plugins/<bundle>/plugins/` on the brand-filtered install tree — not a hardcoded subset — adds each marketplace, and installs every listed plugin by qualified `name@marketplace` at `--scope user`, which enables it in `settings.json enabledPlugins`. Three Claude Code registration surfaces then gate skill loading: `known_marketplaces.json` (marketplace registered), `installed_plugins.json` (a `scope:"user"` row for `name@marketplace`), and `settings.json enabledPlugins` (`name@marketplace` enabled). A nested sub-plugin absent from any of the three loads none of its skills — `Skill(<name>)` returns *Unknown skill* at runtime even though Real Agent's own `account.json enabledPlugins` (stamped by `reconcileEnabledPlugins`, which keys on `plugins/<sub>/PLUGIN.md` presence) lists it.

**Boot-time skill-availability gate.** A manager boot gate ([`skill-registration-drift.ts`](../../../services/claude-session-manager/src/skill-registration-drift.ts)) closes the silent divergence between that Real Agent intent and the Claude Code registration surfaces. It resolves each premium sub named in every account's `account.json enabledPlugins` to its expected `name@marketplace` (nested sub → `maxy-premium-<bundle>`, standalone → `maxy-premium`) by descending the on-disk premium tree, then checks all three surfaces. For each enabled-but-unregistered sub it emits one `[skill-registration] op=drift FATAL accountId=<id> plugin=<sub> expected=<name@marketplace> missing=<surfaces>` line (best-effort mirrored to journald via `systemd-cat -t maxy-csm`), naming exactly which surface lacks the entry. Unlike the specialist-tool-drift gate this one does **not** `process.exit` — a mis-registered sub-plugin must not brick the whole install, so boot continues and the operator keeps a working admin for repair (re-running the corrected installer for the brand). A clean pass emits `startup-self-test skill-registration=ok inspected=<N>`.

**Boot-time tool-availability gate.** The twin of the skill gate for the MCP surface ([`tool-registration-drift.ts`](../../../services/claude-session-manager/src/tool-registration-drift.ts)). Where the skill gate reconciles a premium sub's *skills*, this one reconciles a tool-plugin's *server mount*. Its tool-plugin set is `toolSurface.mcpServers` — every plugin that declares tools and an `mcp:` block, which includes specialist-only tool-plugins like `loop` whose tools are all `adminAllowlist: false` (the admin allowlist never lists them, so an admin-allowlist-derived check would miss them), and shared-lib-server plugins like `graph` (now that the generator emits an `mcpServers` block for them). For each account's `enabledPlugins` that names such a tool-plugin, it inspects the **generated** `.claude-plugin/plugin.json` rather than the PLUGIN.md source: an absent manifest is `reason=not-installed`, a manifest with no `mcpServers` is `reason=no-mcpServers-manifest` (the `skip` signature), and an `mcpServers` manifest whose server dist is missing is `reason=dist-missing` — for a plugin-local `mcp/` that dist is `mcp/dist/index.js`; for a shared-lib plugin the gate resolves the manifest's shared server arg via `${PLATFORM_ROOT}` and checks that binary. Each defect emits one `[tool-registration] op=drift FATAL accountId=<id> plugin=<name> expected-server=<name> reason=<…>` line (best-effort mirrored to journald via `systemd-cat -t maxy-csm`); like the skill gate it does **not** `process.exit`, so a single unmounted tool-plugin does not brick the install. A clean pass emits `startup-self-test tool-registration=ok inspected=<N>`. This is the standing check the no-event failure needs: an unmounted server emits no `[mcp-helper] op=spawn` line, so only a gate that converts its absence into `op=drift` makes it loud.

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

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

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

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

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

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

## Tool Call Audit Trail

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

Each ToolCall record contains:

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

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

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

### Diagnostics

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

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

## Process provenance — durable actions emit Tasks

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

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

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

For estate-agent client work surfaced on the `/tasks` operator page, `work-create` also accepts optional `clientName`, `propertyAddress`, and `fee` (GBP major units) — plain props recorded on the `:Task`, omitted when absent. Billable time against a task is recorded separately as `:TimeEntry` nodes (`(:TimeEntry)-[:LOGGED_AGAINST]->(:Task)`): a running timer is a `:TimeEntry` with `endedAt=null`, timers on different tasks run concurrently, and each task carries a denormalized `secondsLogged` sum for list rendering. A **manual** time correction (the `/tasks` page Edit/Reset controls, `task-time-adjust` route) sets `secondsLogged` to an absolute value and appends an append-only `:TimeAdjustment {adjustmentId, taskId, accountId, at, previousSeconds, newSeconds, delta, sourceSessionKey}` audit record — property-scoped, no edge. So the `[timeentry-census]` invariant is `secondsLogged == sum(TimeEntry.seconds) + sum(TimeAdjustment.delta)`, and an adjust is rejected while the task has an open `:TimeEntry`. The platform records only the time; it computes no charges. See [`.docs/admin-tasks-page.md`](../../../../.docs/admin-tasks-page.md).

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 so listing-curator, content-producer, database-operator etc. inherit the same conversation anchor. The `contact-create` and `memory-write` wrappers call `injectConversationProvenance` (exported from [`@maxy/graph-write`](../../../lib/graph-write/src/conversation-provenance.ts)) which MATCHes `(c:Conversation {sessionId, accountId})` — account isolation is part of the natural key, not a separate gate — and prepends the synthetic `:PRODUCED` edge (composed by Neo4j elementId, which the helper reads off the MATCH). No agent-visible schema field changes. `memory-write` uses the env-stamp only as a fallback when `producedByTaskId` is unset; `contact-create` has no `producedByTaskId` parameter today and relies on the env-stamp alone. Autonomous (cron-driven) specialists with no parent conversation legitimately have no env-stamp; those must thread `producedByTaskId`.

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

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

## Context compaction

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

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

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

# Deployment Guide

## Hardware Requirements

- Raspberry Pi 5 (16GB RAM minimum) with Raspberry Pi OS, **or**
- Mac with macOS 14 (Sonoma) or newer — both Apple Silicon and Intel
- 256GB storage minimum
- Always-on power and network connection
- **On a Pi 5: the official 27W USB-C power supply (5.1V / 5A) and a good cable.** This is not optional — see Power and recovery below.

## Power and recovery (Raspberry Pi)

A Pi 5 running Real Agent draws hard during document ingest: the embedding model runs the CPU flat-out in bursts. An underpowered supply or a thin/long USB-C cable cannot hold 5V under that current spike, the board *browns out*, and you get one of two failures:

- **A reset or freeze** — the board drops out mid-task and either reboots or hangs.
- **A latched-off board** — the board goes dark and stays dark until you physically unplug and replug the power. No software can recover this, because there is no power to run any recovery.

**The cure is hardware.** Use the official Raspberry Pi 5 27W PSU (5.1V / 5A) and a short, good-quality USB-C cable. A marginal phone charger or a cheap cable is the usual cause. This is the only thing that *prevents* a brownout.

**Recovery seatbelts the installer sets up for you:**

- **A hardware watchdog.** If the board *hangs* (powered but stuck), it reboots itself automatically within about two minutes — no manual power cycle. This only helps a hung board; it cannot help a board that has gone dark.
- **Power-health logging.** The admin server writes a `[power-health]` line to `server.log` at boot and every 15 minutes, so a brownout shows up without waiting for a crash. A healthy line reads `throttled=0x0 ... undervolt_occurred=false`. Any `undervolt`-flag set, or `reboots_unclean_24h` above zero, means the board is browning out — fix the power before trusting it with heavy work.

**For a board that latches off (the dark-board case):** put the Pi on a smart plug that you can power-cycle remotely, ideally one driven by a heartbeat (cut power and restore it when the box stops responding). This is the robust recovery for the dark-board failure and is an operator hardware choice, not something the installer can do.

**Keep one address.** After a reboot the Pi can pick up a different DHCP address and look like it vanished. Add a **DHCP reservation** on your router that pins the Pi's MAC to a fixed LAN IP, so the box is always reachable at the same address. The boot `[power-health] op=boot lan_ip=<ip>` line records the address each boot — if it changes across reboots, the reservation is not in place.

## macOS install

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

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

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

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

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

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

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

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

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

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

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

## Initial Setup

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

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

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

## What the Installer Does

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

## First admin session — install-time defaults

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

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

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

## Plugin cache refresh on upgrade

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

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

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

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

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

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

### Per-spawn system-prompt sentinels

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

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

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

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

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

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

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

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

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

### Pre-publish boot smoke

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

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

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

## Service Management

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

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

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

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

## Remote Access via Cloudflare

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

Setting it up: say "Set up remote access." Real Agent 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) — Real Agent never asks you to type your full URL in chat.

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

To check the tunnel status: ask Real Agent "Check Cloudflare tunnel status."

To restart the tunnel: ask Real Agent "Restart the Cloudflare tunnel."

## Checking Service Status

Ask Real Agent: "Check system status."

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

## If Real Agent Won't Start

From the Pi directly:

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

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

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

## Systemd units on each device

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

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

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

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

## Linux laptops: snap-confined Chromium replacement

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

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

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

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

## Plugin registration at install time

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

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

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

Generator schema:

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

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

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

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

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

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

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

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

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

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

## Running multiple brands on one device

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

- **Separate:** each brand has its own install folder (`~/maxy/`, `~/realagent/`), its own config folder (`~/.maxy/`, `~/.realagent/`), its own web port, its own Cloudflare tunnel state, its own edge systemd unit (`maxy-edge.service` vs `realagent-edge.service`), and by default its own Neo4j database (Real Agent on bolt port 7687, Real Agent on 7688). Action runner units are transient and per-invocation, not per-brand, so no naming conflict is possible.
- **Brand-isolated Neo4j:** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
- **Peer-aware system-unit guard:** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Real Agent's database on a host where Real Agent still uses the shared system instance (the earlier platform fixes reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged. The dedicated unit exports `NEO4J_HOME=<per-brand-data-dir>` alongside `NEO4J_CONF`, so `server.directories.run`, `server.directories.plugins`, and `server.directories.import` resolve per-brand — no collision with `/var/lib/neo4j/run/neo4j.pid`. The conf sed-overrides, mkdir-p, chown, and unit-write are idempotent and re-run on every install, so a host whose prior install left a broken unit recovers on retry.
- **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
- **Thermal cap on shared Ollama:** the installer writes a host-wide systemd `CPUQuota` drop-in (`/etc/systemd/system/ollama.service.d/10-maxy-cpuquota.conf`) bounding `ollama.service` to half the cores (200% on a 4-core Pi). An embedding burst — a document's summary plus every section body in one POST — otherwise pins `llama-server` across all cores with no idle gaps until a passively-cooled Pi crosses its soft-temp limit and hard-resets. The cap keeps peak temperature below the limit regardless of which brand or platform path issues the embeds. The drop-in has a neutral filename and is rewritten only when its content changes, so it is idempotent across brands and reinstalls. Applied only where the `ollama.service` system unit exists; a detached `ollama serve` has no unit to cap.

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

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

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

## Version provenance

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

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

Diagnostic when the menu shows the wrong version:

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

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

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

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

## Upgrading

To upgrade Real Agent to the latest version, ask Real Agent: "Upgrade Real Agent." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.

The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.

### Automatic upgrade alert

Real Agent checks for new releases on every admin session start — whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.

The upgrade runs inside a live terminal embedded in the Software Update window — you see each installation step stream as it happens, and any password prompts from `sudo` appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.

The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.

---
# Samba Share
Source: https://docs.getmaxy.com/samba.md

# Samba Share

Every Real Agent install provisions a per-brand SMB network share so you can read and write the brand's install folder from Finder, File Explorer, the Files app on iOS, or any SMB-capable client on Android or Linux. No client install required — every modern OS speaks SMB natively.

The share lives next to the rest of the brand. On a device that runs more than one brand, each brand gets its own stanza, its own credentials, and its own lifecycle. Tearing one brand down never touches another brand's share.

## What gets provisioned

The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:

1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
2. **conf** — writes `/etc/samba/smb.conf` with a `[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`. The `[global]` bind posture depends on the host's interfaces (see "Interface binding" below); the marker records which posture fired — `bind=lan iface=<name>` or `bind=loopback-only`.
3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
4. **units** — `systemctl enable --now smbd` so the share is reachable as soon as the install finishes. `nmbd` (NetBIOS name service) is never enabled: nothing mounts the share by NetBIOS name, and on a public-facing host it answers on `:137`/`:138` as a DDoS-reflection vector.

macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.

## Share path

| Client | Address |
|---|---|
| macOS Finder | `smb://<hostname>.local` then pick the `<brand>` share |
| Windows Explorer | `\\<hostname>.local\<brand>` |
| Linux (`mount.cifs`, Nautilus, KDE) | `//<hostname>.local/<brand>` |
| iOS Files | `smb://<hostname>.local` |
| Android (Solid Explorer, CX File Explorer) | Host `<hostname>.local`, share `<brand>` |

`<hostname>` is whatever the installer printed at the end of `npx @rubytech/create-<brand>-code install` — usually the brand name on a fresh Pi (`maxy-code.local`, `realagent-code.local`). `<brand>` is the same string — it is also the install folder name under the install owner's home.

If `<hostname>.local` does not resolve from your client (some networks do not route mDNS), fall back to the LAN IP: `smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows.

## Credentials

- **Username** — the Unix user that owns the install on the device. On a Pi or Hetzner box this is `admin`; on a self-hosted Linux laptop it is whatever Linux user ran the installer (for example `neo`). The installer persists this value to `~/.<brand>/.install-owner` so every later read uses the same identity the installer wrote.
- **Password** — your current Real Agent PIN. The same PIN that unlocks the admin UI unlocks the SMB share.

Both halves are required. SMB never accepts a guest connection; `map to guest = bad user` is set in the global stanza.

## PIN rotation

There is no separate "SMB password." When you set or rotate the PIN in the admin UI, the platform's `set-pin` route runs `sudo -n smbpasswd -a -s <install-owner>` inline with the new PIN, behind a `NOPASSWD` sudoers grant written at install time and scoped to that exact command.

So:

- On a fresh Pi or Hetzner box, the share is reachable as soon as you set the first PIN. Before that point the `smbpasswd` entry does not exist and the mount fails with a logon error — that is expected.
- Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
- If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.

## Interface binding

`bind interfaces only = yes` is always set, which makes the `interfaces` line an allow-list rather than a hint. Smbd binds only to the interfaces named there. The installer classifies the host's interfaces and writes one of two postures:

- **Private LAN host (Raspberry Pi).** At least one non-loopback interface carries a private address — RFC1918 (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). The installer binds loopback plus that interface (`wlan0` preferred, then `eth0`, then the first other private interface):

  ```
  interfaces = lo <lan>
  bind interfaces only = yes
  ```

  The share is reachable on the LAN and nowhere else.

- **Public-only host (Hetzner Cloud).** The only non-loopback interface carries a routable public address (typically `eth0` with a `/32`, no private LAN). Binding `lo eth0` here would put smbd directly on the public internet, so the installer binds **loopback only**:

  ```
  interfaces = lo
  bind interfaces only = yes
  ```

  The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.

If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.

This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.

## Peer-brand lifecycle

A device that hosts more than one brand carries one stanza per brand in `/etc/samba/smb.conf`. The provisioner is idempotent and peer-safe:

- **Install a second brand** — the new brand's stanza is appended next to the existing one. The shared `[global]` section is rewritten to keep the LAN-only directives current but is otherwise unchanged. Peer-brand stanzas are preserved byte-for-byte.
- **Re-run the installer on an existing brand** — the brand's own stanza is replaced in place. Peer stanzas are not touched.
- **Uninstall one brand** — only that brand's stanza is stripped. `smbd` is then `reload`ed so the brand share disappears from the running config without dropping connections to peer shares.
- **Uninstall the last brand** — after the stanza is removed, the uninstaller checks `hasAnyBrandStanza()` and, if false, stops and disables `smbd`/`nmbd`, runs `smbpasswd -x <install-owner>` to drop the smbpasswd entry, and `apt-purge samba`. If any peer stanza remains, the units stay running and the package stays installed — the uninstaller logs `Leaving smbd/nmbd + samba package in place — other brand stanza remains`.

The brand-stanza name is the only identifier the uninstaller matches on, so two brands with different `BRAND.hostname` values cannot collide.

## Troubleshooting

- **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
- **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
- **Hetzner share not reachable from outside the box.** This is by design — see "Interface binding" above. A public-only host binds loopback only; use SSH port forwarding (`ssh -L 4445:localhost:445 …`).

Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.

---
# Troubleshooting
Source: https://docs.getmaxy.com/troubleshooting.md

# Troubleshooting

## Stream-log file for a fresh session is absent or empty

**Symptom:** Operator opens a new admin session, sends one turn, sees the agent reply, then `logs-read sessionKey=<…>` returns `file-not-found` or zero bytes.

**Invariant:** For every new session, the stream-log file exists on disk iff at least one token byte has been emitted, and contains the token bytes from the moment the first token returns to the operator. The single-writer mandate (2026-05-14) mechanically enforces both halves of the contract: the single writer module at `platform/ui/app/lib/claude-agent/stream-log-writer.ts` opens the file lazily on `streamLog.writeToken` (the SDK first-byte site at [`stream-parser.ts:296`](../../../ui/app/lib/claude-agent/stream-parser.ts#L296)), and the build gate `platform/ui/scripts/check-stream-log-writer.mjs` rejects every external `appendFileSync`/`createWriteStream` against the `claude-agent-stream-*` pattern at CI time. The first-token invariant is bound by `platform/scripts/__tests__/first-token-creates-stream-log.test.sh`: one operator turn, one token, `claude-agent-stream-<sessionKey>.log` exists and contains the token bytes — pass iff file present and bytes present. The hourly adherence runner `platform/scripts/log-adherence-check.sh` extends the device-side check with a duplicate-basename diagnostic (`dup-basenames=N` in the `[log-tee] adherence-check` line); `dup>0` is a P0 page meaning the writer collapse regressed.

**Diagnose if it ever recurs:** run `bash platform/scripts/__tests__/first-token-creates-stream-log.test.sh` from the install. Pass = invariant holds; any other exit = the writer-side existence contract is broken and one `[log-tee] missing-on-resolve sessionKey=<8> surface=<…>` line on `server.log` is the operator-visible signal (P0). For the duplicate-file class specifically (the 2026-05-14 recurrence trigger), `bash platform/scripts/log-adherence-check.sh` returns non-zero whenever any sessionKey has more than one `claude-agent-stream-<sk>.log` across account dirs.

## Retrieving evidence from an rc-spawn session

rc-spawn sessions (those started via the sidebar or the `claude rc --spawn` daemon) do not write a per-account stream log under `data/accounts/<id>/logs/`. Their evidence is the Claude Code JSONL transcript in the configDir:

```
<CLAUDE_CONFIG_DIR>/projects/<slug>/<uuid>.jsonl                      # parent session
<…>/projects/<slug>/<uuid>.meta.json                                  # bridgeIds persistent map
<…>/projects/<slug>/<uuid>/subagents/agent-<hex>.jsonl               # each subagent
<…>/projects/<slug>/<uuid>/subagents/agent-<hex>.meta.json           # {"agentType",…}
```

**Retrieve a session's merged timeline:** `logs-read.sh <key>` with a bare key (no second argument) maps the key to the local `<uuid>` and prints one timestamp-ordered timeline merging the parent transcript with every subagent transcript. The key is resolved in order: a matching `<uuid>.jsonl` on disk; a `sessions/<pid>.json` whose `bridgeSessionId` matches; a `<uuid>.meta.json` whose `bridgeIds` carries the suffix (persistent — survives PID-file cleanup on clean exit); and finally a content scan of the top-level transcripts as last resort. Any accepted key form works: the `claude.ai` `session_<id>`, its bare suffix, or the `<uuid>` (or a unique uuid prefix).

Every subagent `is_error` tool_result is flagged inline as `‼ SUBAGENT ERROR` with the agent type, the failing tool, and the error text. The parent session's own tool errors appear as `‼ tool error`. The two are never conflated.

**Audit all silently-failed subagents:** `logs-read.sh --scan-subagent-errors [N]` walks every `subagents/agent-*.jsonl` under the configDir and lists each one carrying an `is_error` result — agent type, parent session, failing tool, error text. Optional `N` limits the scan to the `N` most-recently-modified transcripts. Use this when a delivery failure was reported but no reproduction is available.

**Quick recipes:**

```bash
# A session's merged parent+subagent timeline (subagent errors flagged inline)
~/maxy-code/platform/scripts/logs-read.sh session_<id>

# Standing audit: every subagent transcript that failed silently
~/maxy-code/platform/scripts/logs-read.sh --scan-subagent-errors

# Limit audit to the 50 most-recent transcripts
~/maxy-code/platform/scripts/logs-read.sh --scan-subagent-errors 50
```

Note: passing an explicit second argument (e.g. `logs-read.sh <key> agent-stream`) still reads the legacy per-account stream log — the bare-key JSONL path is the default when no type is given.

## A JavaScript-rendered page comes back empty from WebFetch or `url-get`

**Symptom:** A page that needs JavaScript to show its content returns empty or a shell document from `WebFetch` (summary) or `url-get` (verbatim, server-rendered).

**Resolution:** Use the `browser` core plugin's `browser-render` tool. It renders the page in the device's per-brand Chromium over the Chrome DevTools Protocol (the same browser the VNC viewer shows) and returns the rendered HTML plus visible text. It attaches to the already-running Chromium on `127.0.0.1:${CDP_PORT}` — nothing is downloaded or installed mid-session.

**Diagnose if it ever recurs:** grep the per-conversation stream log for `[browser-render]`. `rendered=true domBytes=<n>` is the healthy signal. `rendered=false outcome=cdp-unreachable` means no Chromium is listening on the brand's CDP port — confirm with `curl 127.0.0.1:<cdpPort>/json/version`. Other outcomes (`navigate-failed`, `load-timeout`, `evaluate-failed`) name the failed CDP step.

## First user-domain write rejected by `[graph-write-gate] reject reason=no-admin-user`

**Symptom:** Admin chat reports "couldn't save that — set up your business profile first" or `[graph-write-gate] reject reason=no-admin-user` appears in `server.log` on the operator's first non-bootstrap write (a website, service, opening hours, etc.). Reproduces on Minimal-onboarded installs from before the seed-stamping fix shipped.

**Diagnose:** Tail the gate reject and self-heal lines together:

```
grep -E "adminuser-self-heal|graph-write-gate.*reject" <server.log>
```

- `[adminuser-self-heal] healed=1 …` followed by no `[graph-write-gate] reject` lines on subsequent writes — heal fired, the gate is now passing. Operator can retry.
- `[adminuser-self-heal] healed=0 …` + `[graph-write-gate] reject … subReason=admin-user-no-accountid` — heal couldn't reach the broken node. Most likely cause: the env-side `ACCOUNT_ID` doesn't match any `:AdminUser.userId`. Cross-check `users.json[0].userId` against `MATCH (au:AdminUser) RETURN au.userId, au.accountId` — if the userId mismatches, the `[admin-invariant]` line in the same log will show `direction=users-without-account` and the repair is to align the stores per `.docs/agents.md` § "Three-store admin auth invariant", not to retry the heal.
- `[graph-write-gate] reject … subReason=no-admin-user-node` — the graph has no `:AdminUser` at all. Re-run the seed (`platform/scripts/seed-neo4j.sh`) under the install's env vars; the boot self-heal won't help because there's nothing to heal.

The `subReason=admin-user-no-accountid` path should be impossible on any install whose admin server has booted at least once after the boot self-heal shipped — if it fires, the diagnostic recipe is the cross-check above, not "rerun the heal."

## An admin can no longer log in, or a credential changed and you don't know who/when

**Symptom:** An admin's remote password or PIN stopped working, or an admin row vanished from `users.json` while `account.json` still lists them — with no obvious cause in `server.log`.

**Diagnose:** every credential-store write is recorded in a dedicated append-only log, separate from `server.log`:

```
grep -E '\[users-audit\]|\[users-reconcile\]' ~/.<brand>/logs/users-audit.log ~/.<brand>/logs/server.log
```

- `[users-audit] action=<…> actor=<id8> [session=<id8>] field=<…> rowsBefore=<…> rowsAfter=<…> ts=<…>` — one line per write. Filter by `actor=` or `session=` to attribute a single mutation; a credential change with an agent behind it carries `session=` even when that session's transcript is gone. A `users.json` mtime change with **no** `[users-audit]` line in the same window means an out-of-band write (something edited the file directly, outside every audited path).
- `[users-reconcile] op=ok admins=<n>` — emitted at boot and hourly; the stores agree. Absence of any `op=ok`/`op=divergence` line across an interval is itself the failure signal (the tick stopped).
- `[users-reconcile] op=divergence accountOnly=<id8,…> usersOnly=<id8,…>` — the stores disagree. `accountOnly` is the incident shape: an admin is in `account.json` with no `users.json` row (they will fail every login). Cross-check the most recent `[users-audit] rowsAfter` against the divergence to see whether an audited delete caused it or an out-of-band write did. `usersOnly` is a `users.json` row no account references.
- `[users-reconcile] op=error detail=…` — the reconcile itself could not read a store; the divergence state is unknown until the next clean tick.

## Fresh install opens to "Set your remote password" on the LAN URL

**Symptom:** On a brand-new device, the LAN URL printed by `create-maxy` (e.g. `http://maxy.local:19200`) opens to a remote-password setup page instead of admin onboarding. This was an earlier regression and should not occur on any install built.

**Diagnose:** On the Pi, grep the UI server log for the gate's disambiguation fields:

```
tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
```

- `resolvedKind=lan` on a `login required` or `not configured` line means the classifier sees the request as local — if the browser is still on the remote-auth page, something cached the older page before the fix shipped (hard-refresh the tab).
- `resolvedKind=external` means the request chain presents as remote (routable IP in the first `x-forwarded-for` hop). On a LAN-only browser this points to a proxy or VPN rewriting headers between the browser and the Pi.
- `resolvedKind=unknown` is a defect — the classifier could not identify the TCP peer. Capture the log line and file it; do not work around it.

**Fix:** If all three fields confirm the LAN shape and the gate still refuses, upgrade the platform (`Software Update` from admin chat) to pick up the updated classifier.

---

## Remote sign-in is rejected with "Remote access requires TLS"

**Symptom:** Posting the remote-auth password returns a plain-text `400 Remote access requires TLS` response instead of completing sign-in.

**What this means:** The login endpoint will only issue a session cookie when the request arrived over HTTPS (via the Cloudflare tunnel). Browsers silently drop `Set-Cookie: Secure` on plain-HTTP responses, so minting a cookie there would produce a dead-end redirect. An earlier fix replaced that silent failure with this loud one.

**Fix:** Reach the admin surface through the tunnel hostname (e.g. `https://admin.<your-domain>`), not an IP or plain-HTTP URL. If you need LAN access, use the LAN URL (`http://<hostname>.local:<port>`) — LAN never hits the remote-auth endpoint.

---

## Agent Not Responding

**Symptom:** You send a message and nothing comes back, or the response never arrives.

**Check:**
1. Ask Real Agent: "Check system status" — the `system-status` tool will report whether all services are running
2. Check the platform logs: ask Real Agent "Show me the recent logs"
3. If the admin agent itself won't start: restart the platform (see below)

**Common causes:**
- Claude API connectivity issue — check your Claude OAuth connection is still valid
- Platform process has stopped — restart it
- Network issue if accessing remotely — check your Cloudflare tunnel is running

**If the chat shows a single `[agent-loop-stop] same error twice — aborting` line and stops:** Real Agent 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 Real Agent picks up where it aborted instead of cold-querying its own session list. To see the diagnostic, ask Real Agent: "Show me the most recent stall-recovery log line." Greppable post-deploy invariants: `[agent-loop-stop] reason=identical-tool-failure tool=<name> errorSignature=<sha8> toolInputDigest=<sha8>` followed by `[stall-recovery] kind=agent_loop_stop … handoff=resume-first` and on the next turn `[stall-resume] consumed kind=agent_loop_stop toolUseId=<8> priorSessionId=<8>`. The fallback path (when the SDK session id was lost) emits `handoff=metadata-only` + `[recovery-handoff] generated/consumed reason=agent-loop-stop` and the chat button reads "Start over" instead of "Continue". A `[recovery-handoff] WARN missing-on-cold-create` line means the fallback briefing wasn't persisted — surface to support.

**If a background task goes silent and the chat shows "A background task went silent — K of M completed":** Real Agent'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.

**The stop button is stuck and the spinner never clears.** If the chat shows a stop button and a working spinner that never resolves, and the session dot is grey (not live), the agent process for that session was killed mid-turn (most often the device ran low on memory). There is no live turn to interrupt, so the old behaviour left you stranded. Now it self-heals: within a few seconds the spinner clears on its own and the composer shows "The agent stopped. Send a message to resume." Pressing stop clears it immediately. Either way, just send your next message — the session resumes from where it left off. Greppable post-deploy invariants: the manager logs `[rc-life] op=turn-aborted reason=child-exit-mid-turn` on the death and `[webchat-interrupt] op=dead-child-recovered` if you press stop; the browser console logs `[admin-ui] op=phantom-turn-cleared` (self-heal) or `[admin-ui] op=stop-dead-child` (stop press). If a session keeps dying mid-turn, the device is memory-constrained — that is a capacity concern, separate from this recovery.

**Agent searches the filesystem after uploading a zip.** If you uploaded a zip and the agent burns several turns running `find` / `Glob` instead of unzipping, that is the symptom of the recovery-retry attachment-context regression (now closed by the recovery context preservation contract in `.docs/agents.md`). Greppable confirmation is the `[context-overflow-recovery] retry … attachmentsCarried=<n>` line in the conversation stream log. If you see `[context-overflow-recovery] WARN attachment-context-lost`, the regression has returned — surface to support.

**Turn budget exhausted with a horizontal rule separating two assistant turns.** When Real Agent reaches its turn budget and the doubled retry also runs out, the chat now shows a one-paragraph assistant message that opens with `error_max_turns turns=A→B` (initial budget → final budget) followed by the recovery copy: "I reached my turn budget of N before I could finish this request. Try sending a smaller or more focused request, or ask me to use higher effort." That message is persisted to the graph, so the next page-refresh still shows it. The thin horizontal rule labelled "Session restored after timeout." that appears above your following turn signals that the prior turn forced a cold SDK-session restart inside the same conversation (pool eviction) — the agent's response after the rule is from a fresh SDK session even though the conversation thread is unchanged. Greppable post-deploy invariants: `[context-overflow-recovery] exhausted cause=max-turns-interrupted` count equals `[admin-persist] writer=persistMessageExhaust outcome=ok` count for the same sessionId window, and one `[session-store] storeAgentSessionId` line marks the cold-restart that drove the on-screen rule.


**A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-session stream log (`logs/claude-agent-stream-<sessionKey>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
**Wrong Claude account answering on a multi-brand device.** On a host running both Real Agent and Real Agent, each brand's admin agent reads its own `~/${brand.configDir}/.claude/.credentials.json`; there is no longer a shared `~/.claude/` thrashing them against one another. If a brand reports auth failures or appears to be operating against the wrong subscription, check three things:
1. `grep "\[claude-auth\] init" ~/.${brand}/logs/server.log | tail -1` — the resolved path must end with `~/.${brand}/.claude/.credentials.json`. If a `[claude-auth] WARN cross-brand-path-detected` line is present, the runtime is still pointing at `~/.claude/`; the brand main service did not pick up the `Environment=CLAUDE_CONFIG_DIR=` setting (re-run the brand installer to refresh the unit file).
2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
3. `grep "\[install\] claude-creds pickup" ~/.${brand}/logs/install-*.log` — fires once on the first 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).

To deliberately disconnect the wrong account, use the header menu's **Disconnect Claude** item (the supported path; no SSH or manual file deletion needed). It runs `claude auth logout`, then verifies the brand's `~/${brand.configDir}/.claude/.credentials.json` is actually gone before reporting success — a disconnect that left the credential in place reports failure in the menu rather than false-confirming. The server logs one line per attempt: `grep '\[onboarding\] op=claude-logout' ~/.${brand}/logs/server.log` — `credentialsPresent=true` after the call is a failed disconnect the operator may believe succeeded.

**All sessions on the brand stopped responding after a token expiry.** Symptom on the operator side: every spawn dies at `pid-file-timeout` and the dashboard health probe reports auth dead. Diagnose the OAuth refresh path before anything else:

1. `tail -n 300 ~/.${brand}/logs/server.log | grep -E 'auth-refresh|auth-health|invalid_grant'` — `op=lock-acquired` proves the cross-process lock is in play. `op=skipped-fresh` means a sibling process (the admin server or a `claude` binary) already rotated the tokens during the lock wait — expected, healthy. `op=renewed expiresAt=…` is the only line that means a network refresh actually ran.
2. `outcome=fail-token` or `invalid_grant` lines mean Anthropic rejected the refresh token itself (revoked or expired beyond the rotation window). The brand needs a fresh `claude /login`. Pre-576 the most common cause was the admin server and a spawned `claude` racing to rotate the same single-use refresh token; that race is now serialised by the file lock at `~/.${brand}/.claude/.credentials.json.lock` and a re-read after the lock skips redundant refreshes.
3. `grep '\[auth-health\]' ~/.${brand}/logs/server.log | tail -n 5` — the heartbeat fires every five minutes. `status=dead expiresIn=...` means the refresh token is gone; only a re-login fixes it. `status=ok` heartbeats with no spawns in between mean the credentials file is healthy and the failure lives elsewhere.
4. The spawn-failure surface now carries `reason=auth-refresh-failed` (with `authStatus` in the JSON body) instead of generic `pid-file-timeout` whenever the credentials file is in `dead` or `expired` state at the moment of failure — visible in `grep '\[spawn-failed\]'` on server.log.

**A WhatsApp message you can see on your phone never appears in the app.** First check whether it was ever stored, rather than assuming it routed elsewhere: ask Real Agent for the most recent ingest reconcile, or `grep '\[wa-ingest-audit\]' ~/.${brand}/logs/server.log | tail -1`. A healthy line reads `op=reconcile … unpersisted=0 inFlight=0 guard=installed`. If `unpersisted` is above zero, the `ids=` field names the exact message ids that arrived at the wire and were never written to the store — those messages are genuinely lost to the platform, not hidden in another workspace or sub-account, and they cannot be re-fetched because WhatsApp only delivers an acked message once. Forward the message from your phone to get it back in; it re-enters as a new message through the normal path.

The audit runs every 15 minutes and holds a stanza for 10 minutes before judging it, so a message can take up to 25 minutes to be reported. The wait is deliberate: a large batch of photos arriving at once can take several minutes to finish storing, and a false "a message was lost" alarm would make the real one easy to ignore. Three classes are resolved rather than reported, each counted separately: `skippedStatus` (status broadcasts, never stored), `skippedReaction` (reactions, stored under the message they react to rather than under their own id — a consequence being that a lost *reaction* is not detectable), and `declined` (messages carrying neither text nor a file, such as a shared contact card, a poll, or a delete-for-everyone, which are dropped by design). `oldestUnpersistedMin` tells you whether a reported loss is new or one you have already seen, since the same id is re-reported every 15 minutes for a day. `guard=absent` or `inFlight` stuck above zero on an idle install are both faults worth surfacing to support: the first means the media-download crash guard failed to install at boot, the second means its counter leaked. `contained` counts crashes the guard prevented — a number that climbs while media traffic stays flat is also worth reporting.

---

## Memory Not Working

**Symptom:** Real Agent doesn't remember things you've told it, or search returns nothing.

**Check:**
1. Ask Real Agent: "Check the Neo4j connection"
2. Ask Real Agent: "Search memory for [something you know was stored]"

**Common causes:**
- Neo4j service stopped — restart the platform, which restarts Neo4j
- Memory index is stale — ask Real Agent: "Reindex memory"

---

## Telegram Bot Not Receiving Messages

**Symptom:** You send a message to the bot and nothing happens.

**Check:**
1. Confirm the bot token is correct: ask Real Agent "What Telegram bot token is configured?"
2. Verify the bot is running: send `/start` to the bot in Telegram
3. Check the MCP server logs: ask Real Agent "Show Telegram plugin logs"

**Common causes:**
- Bot token changed (if you regenerated it in BotFather) — update it by telling Real Agent "Update my Telegram bot token"
- Webhook not connected — restart the platform

---

## Plugin Errors

**Symptom:** A tool fails with an error, or a plugin says it can't connect.

**Check:**
1. Ask Real Agent: "Show me recent errors"
2. Ask Real Agent: "Restart the [plugin name] plugin"

**Common causes:**
- Missing environment variable (API key, token) — the error message will name it; ask Real Agent to help configure it
- MCP server crashed — restarting the platform restarts all MCP servers

---

## Cannot Mount the SMB Share

**Symptom:** Mounting `smb://<hostname>.local` (or `\\<hostname>.local\<brand>`) fails with a "logon failure" or the share does not appear in your network browser.

**Check:**
1. Confirm you have set a PIN in the admin UI at least once. On a fresh Pi or Hetzner box the `smbpasswd` entry does not exist until the first set-pin runs — mounts before that point always fail.
2. Use the install owner as the username (`admin` on a Pi or Hetzner box; the Linux user that ran the installer on a self-hosted laptop) and the current Real Agent PIN as the password. The SMB password is not stored separately — it is the PIN.
3. If `<hostname>.local` does not resolve from your client, mount by LAN IP instead (`smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows).
4. Rotate the PIN in the admin UI. That re-triggers the `smbpasswd` sync on the device. If the resync log line reads `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing`, restore `~/.<brand>/.install-owner` from the installer log.

See [Samba Share](./samba.md) for the full credential model and per-OS mount syntax.

---

## Restarting the Platform

From the admin interface, ask Real Agent: "Restart the platform."

If Real Agent itself isn't responding (the page loads but the agent won't connect), try refreshing the browser. If the page itself won't load, the platform process may have stopped — power-cycle the Raspberry Pi by unplugging and reconnecting power, then wait a minute for services to restart automatically.

---

## Checking Logs

Ask Real Agent: "Show me the logs" or "Show errors from the last hour."

For specific plugin logs: "Show Telegram logs" or "Show contacts plugin logs."

Real Agent has access to all platform logs and can filter them for you.

---

## Box went unreachable, suspected memory exhaustion

**Symptom:** The Pi stopped responding to everything (no web, no SSH, no ping) and needed a power-cycle. This is the signature of memory exhaustion: the box fills RAM and swap, then wedges while the kernel tries to free memory.

**What protects against it:** Every install runs a standing memory audit every 30 seconds, zram compressed swap so pressure hits RAM instead of the SD card, and earlyoom, which kills the single largest process before the kernel freezes the box. A kill shows up as one service restarting, not a whole-box outage, and SSH is protected so the box stays reachable.

**Check the approach to OOM after a reboot.** The audit log survives reboot. Read it:

```
~/<install>/platform/scripts/logs-read.sh --tail mem 100
```

Replace `<install>` with the install directory (for example `realagent-code`). Then:

- Look for `op=mem-pressure` lines. They fire when available memory crosses the low-water mark, before any kill, and name the heaviest service (`topSlice`).
- A single service's reading (`ptys`, `neo4j`, `brand`, `edge`, `vnc`) rising over time while the others stay flat is a memory leak in that service.
- `op=mem-sample` lines that read `na` for the per-service fields are normal until the kernel memory controller is enabled on that box; available memory, swap, and the pressure warning still work.

**Confirm a kill happened and the service came back:**

```
journalctl -t earlyoom --since -1h
journalctl --user -u <hostname>.service --since -1h | grep -iE "Started|Scheduled restart"
```

**Check whether the kernel OOM-killer fired anyway** (it should not, if earlyoom pre-empted it):

```
journalctl -b -1 -k | grep -i oom
```

A kernel `invoked oom-killer` line means the mitigation did not pre-empt the kernel on that occasion. A gap in the `op=mem-sample` timestamps means the sampler itself stopped. Both are worth escalating.

---

## Cloudflare Tunnel Down (Remote Access Broken)

**Symptom:** You can reach Real Agent on your local network but not via your public domain.

**Check:** Ask Real Agent "Check the Cloudflare tunnel status."

**Fix:** Ask Real Agent "Restart the Cloudflare tunnel."

If the tunnel won't reconnect, re-run the Cloudflare setup: ask Real Agent "Reconnect Cloudflare."

If the initial Cloudflare login fails during setup, Real Agent will fall back to asking you for a connection key. You can create one in the Cloudflare dashboard (Real Agent will guide you through this in the browser).

**If you switched Cloudflare accounts or are stuck on the wrong one:** ask Real Agent "Reset my Cloudflare login and start over." This is a clean reset — Real Agent clears every stored credential, then opens a fresh browser sign-in. The next sign-in binds to whichever Cloudflare account you choose, with no risk of the previous account's stored credentials silently coming back.

---

## "Bad Gateway" or holding page during an upgrade

`maxy-edge.service` (always-on front door) classifies upstream errors and serves a brand-aware response. There are two distinct user-visible shapes; the right one depends on what failed.

**Branded holding page (brand logo + "Starting") for ~10 s during an upgrade — this is expected and self-healing.** The edge process binds the public port immediately, but `maxy.service` (the upstream UI) takes ~10 s after restart to apply the neo4j schema and mount its 11 routes. Any browser navigation that lands during that window gets a self-contained HTML holding page that polls `/api/health` and reloads automatically once the upstream binds. The page renders the brand logo (inlined as a base64 data URI at edge boot from `<install>/server/public/brand/<assets.logo>`) and the brand display/body fonts (loaded from fonts.googleapis.com) — both paths bypass the unavailable upstream so the page never makes a same-origin asset fetch. When `brand.logoContainsName` is true the logo replaces the productName text; otherwise the page falls back to "Real Agent is starting". No operator action required. The diagnostic line in `~/.maxy/logs/edge.log` is `[edge] ts=<ISO-8601> 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. `ts=` is what lets a run of these be read as an outage with a start, an end and a duration, and cross-read against `journalctl --user -u <brand>-code.service`; `path=` carries the endpoint only, never the query string, because this line fires on paths whose query can hold a download signature, an OAuth code, or a customer phone number. Boot-time confirmation that the logo resolved: `[edge] brand=<name> holding-logo=inlined assets-dir=<path>` — `holding-logo=missing` means the logo file wasn't found at `assets-dir`, the page degrades to text-only.

**Branded plain-text 502 ("Bad Gateway (Real Agent unavailable)") — real upstream failure, not cold-start.** Any error class other than `ECONNREFUSED` (timeouts, resets, host-unreachable) returns the existing 502 path. The diagnostic line carries `err-class=other`. Read the log with `tail -200 ~/.maxy/logs/edge.log | rg 'err-class=other'` and check `~/.maxy/logs/server.log` for upstream stack traces — the upstream itself is the source.

**Continuous `err-class=econnrefused-coldstart` for >30 s past the last `[edge] listening` line** indicates the upstream never binds — the upgrade or boot has stalled. Recover via `sudo systemctl --user status maxy.service` and check the action runner log per the next section. Permanent-failure UI escalation (turning the holding page into an error after N seconds) is intentionally deferred.

**The literal string `maxy-ui` should never appear in `edge.log` or in any user-visible 502 body**, regardless of brand. If it does, the edge is running stale code — re-bundle and re-publish.

**Verifying the holding page locally:** `curl -sS -H 'Accept: text/html' http://127.0.0.1:<EDGE_PORT>/` while `maxy.service` is stopped should return HTML containing the brand `productName`. The `Accept: text/html` header is required — non-html clients (default `curl`, `fetch`, XHR) get the branded plain-text 502 instead, so the holding page's own `/api/health` polls don't break themselves during cold-start.

---


## Software update and Cloudflare setup

Both flows run on the native Claude Code PTY surface in admin chat. The retired action-runner / terminal-modal troubleshooting sections that lived here have been removed because those surfaces no longer exist; failures now manifest as plain stderr from the agent-invoked Bash command, visible in chat.

- **Software update.** Re-run `npx -y @rubytech/create-<brand>@latest` from a shell; if the installer fails, its stdout is the diagnostic record. HeaderMenu turns sage when `installed === latest`.
- **Cloudflare setup.** The agent invokes `cloudflared` directly via Bash, following the cloudflare plugin's `plugins/cloudflare/references/manual-setup.md`. Failures surface as cloudflared's literal stderr plus a non-zero exit. Recovery paths live in `plugins/cloudflare/references/reset-guide.md` and `plugins/cloudflare/references/manual-setup.md`.

## Orphan Account Directory Archived to `.trash/`

**What happened:** During upgrade, the installer detected multiple account directories under `~/maxy/data/accounts/` and identified one as live (its `admins` list matches the device's `users.json`). Non-matching siblings are archived — not deleted — under `~/maxy/data/accounts/.trash/<uuid>-<ISO8601-ts>/`.

**Installer signal:** Look for these lines in the installer log or admin terminal output:

```
==> [seed] identity-match: kept=<uuid-short> via userId=<first-8>
==> [seed] swept orphan: <uuid-short> →.trash/<uuid-short>-<ts>
==> [seed] orphan sweep: moved N → ~/maxy/data/accounts/.trash/
```

**Rollback (if the wrong account was kept):** The archive is preserved verbatim. Stop the platform, move the desired directory back, restart:

```bash
sudo systemctl --user stop maxy-ui
mv ~/maxy/data/accounts/<live-uuid> ~/maxy/data/accounts/.trash/<live-uuid>-$(date -u +%Y%m%dT%H%M%SZ)
mv ~/maxy/data/accounts/.trash/<archived-uuid>-<ts> ~/maxy/data/accounts/<archived-uuid>
sudo systemctl --user start maxy-ui
```

**`.trash/` retention:** Archived directories are kept indefinitely. The platform never auto-empties `.trash/`. When you're confident the archived orphans are truly obsolete, remove the directory manually: `rm -rf ~/maxy/data/accounts/.trash/<uuid>-<ts>/`.

**Installer aborted with "identity-match FAILED":** Multi-account installs where no sibling matches `users.json[0].userId` abort loud — the installer refuses to pick one and refuses to sweep. Resolution: inspect `account.json` in each candidate dir (listed in the abort output), identify the correct owner, move the other(s) aside manually, then re-run the installer.

**A chat turn looks broken — assistant bubble never rendered:** Open `claude-agent-stream-<sessionKey>.log` and grep for `[sse-client]`. The five phases (`connected`, `event_received`, `render_complete`, `error`, `close`) tell the story in order. Missing `connected` = the chat fetch never returned 200; missing `event_received` = the server emitted nothing or the client lost the stream before the first frame; missing `render_complete` = the reducer never committed the assistant bubble (persist_ack never arrived).

## Admin DevTools console floods with `onboarding-banner-mount` or `sessions-poll` lines

**Regression symptom.** Open DevTools on the admin shell at `/` with `onboardingComplete=false`, leave the page idle for a minute, then scroll back through the console. Thousands of `[admin-ui] onboarding-banner-mount onboardingComplete=false` lines (one per AdminShell render, ~40/min driven by the 3s sessions poll) with no per-tick poll telemetry indicates the banner-mount log has regressed back into the render body.

**Steady-state invariants at `/`:**

- `grep -c '\[admin-ui\] onboarding-banner-mount' ~/.maxy/logs/admin-ui-console.log` equals page-load count plus onboarding-flip count, not the render count. Sustained climb at idle means the banner mount log regressed back into the render body (fix).
- `grep -c '\[admin-ui\] sessions-poll' ~/.maxy/logs/admin-ui-console.log` over a 60-minute idle window equals zero. The hook no longer installs a `setInterval`; every `sessions-poll` line is operator-triggered (initial mount, refresh button, post-mutation refetch). One or more lines during operator idle means `setInterval` was reinstated.
- `outcome=error` lines name a real fetch failure on an operator-triggered refetch, set the `error` field, and surface in the sidebar.

**Reconcile signal:**

- `grep -c '\[admin-ui\] sidebar-meta-pane-reconcile' ~/.maxy/logs/admin-ui-console.log` should equal the count of End / Resume / Purge clicks while the metadata pane was open. A `to=gone` line without a paired Close click means the pane's auto-close logic regressed.

**Why this matters.** The render-body log was misleading: it read as "the admin agent is checking onboarding state continuously", when in fact `onboardingComplete` had not changed at all. The fix moved the log into `useEffect(…, [])` then dropped the per-tick poll entirely, so a quiet console is now the steady state. With both fixes in place, console output is a faithful record of what the page actually did each operator click.

## Typing in the `/chat` composer is slow to render

**Symptom.** In the admin/operator `/chat` webchat, typing into the message box lags. Characters appear behind the keystrokes, worse on a long conversation. CPU, RAM, and network show no pressure.

**Cause.** A controlled-input re-render storm. The composer text lives in the chat host, and `<Transcript>` is a sibling under that host, so each keystroke re-renders the host and reconciles the whole transcript tree. Cost scales with transcript length. The "no CPU/RAM spike" reading is consistent, not contradictory: React reconciliation is short main-thread bursts, invisible in Activity Monitor averages; the felt symptom is per-keystroke input latency. See the webchat composer/transcript render contract in `.docs/platform.md`.

**Diagnostic.** Open the surface with `?diag=composer` appended to the URL and watch the browser console while typing on a long conversation. Each `[diag:composer]` line is a transcript reconcile attributed to a keystroke, with its `actualDuration` (reconcile cost) and `latency` (keystroke to commit). Correct behaviour logs zero lines per keystroke and the transcript still streams and follows the tail. One or more lines per keystroke, or a latency that grows with conversation length, means the decoupling regressed: a keystroke state re-hoisted above the `<Transcript>` sibling, or a `<Transcript>` prop identity that now churns on a keystroke-only render.

---
# Real Agent Roles
Source: https://docs.realagent.network/real-agent/roles.md

# Real Agent Roles

The `real-agent` premium bundle ships five additional roles on top of the Real Agent core specialists. Real Agent dispatches them by intent — you do not name them.

The table below is the install catalogue for the `real-agent` bundle. It is not evidence of what is enabled on the current account. For the live install set on this account, ask Real Agent to call `capabilities-here`.

| Role | What it does |
|------|-------------|
| Buyer Enquiry | Public-facing buyer enquiry agent — answers visitor questions about properties, viewings, and the buying process from the agency knowledge base. Operates inside UK property conventions, never gives financial or legal advice, and collects contact details only when the visitor asks to book a viewing. |
| Compliance | Your compliance officer — advises on UK estate agency law, audits listings against regulatory requirements, and cites current legislation. |
| Listing Curator | Your listing curator — maps property inputs to typed :Listing nodes that visitor-facing chat can render. |
| Negotiator | Your negotiator — manages buyer pipeline, viewings, feedback, offers, and deal progression using Loop CRM. |
| Valuer | Your valuer — prepares market appraisals, assembles comparable evidence, and analyses local market conditions using Loop CRM. |

The four sales-side roles (Compliance, Listing Curator, Negotiator, Valuer) are admin-side specialists dispatched from your operator session. Buyer Enquiry is a public-facing agent the visitor-side chat surfaces on your domain.

---
# Real Agent Plugins
Source: https://docs.realagent.network/real-agent/plugins.md

# Real Agent Plugins

The `real-agent` premium bundle ships twelve sub-plugins. Each is independently activatable via `enabledPlugins`; the bundle auto-stamps every sub on first boot after install. Disable an individual sub with `plugin-toggle-enabled`.

The table below is the install catalogue for the bundle. It is not evidence of what is enabled on the current account. For the live install set, ask Real Agent to call `capabilities-here`.

| Plugin | What it does |
|--------|-------------|
| `brochures` | End-to-end pipeline for producing print-ready A4 estate-agent property brochures. Single-entry orchestrator that skips upstream steps whose outputs already exist: extracts brand from the agent's website, extracts property assets from the listing URL, lays out the brochure, and satisfies print constraints for pixel-perfect PDF. |
| `buyers` | Full buyer lifecycle — enquiry handling, qualification, viewing management, feedback collection, educational guides, the public-chat property recommender, and the visitor-graph analytics that link page behaviour to known :Person contacts. |
| `estate-business` | Monthly close, commission, payments, and business operations for UK residential estate agencies. Includes month-end-close (period-reconciler, commission-calculator, payment-batch-stager) plus business-growth, business-operations, personal-branding, and exp-partnership. |
| `estate-coaching` | Coaching, training, and performance management for estate agency teams — personalised feedback, goal-setting frameworks, and sales methodology training. |
| `estate-sales` | Sale-agreed to completion progression for UK residential sales agents. Headline workflow is chase-progression: daily ranked stalled-deal surfacing with tone-matched draft messages and per-message approval gates. Includes risk-scorer (fall-through risk scoring) plus sales-discovery, sales-closer, sales-negotiation, and sales-progression. |
| `estate-teaching` | Structured education module browsing and delivery — curriculum navigation, module teaching, and learning progress tracking. |
| `leads` | Daily morning round and lead-pipeline skills. Composes diary, awaiting-reply, overnight-enquiry triage, and stalled-deal surfacing into one snapshot, plus the chain-progression-tracker shared with chase-progression. |
| `listings` | Valuation prep and new-instruction onboarding. Includes valuation-prep (comparable-finder, local-market-stats, pricing-scenario-builder, talk-track-composer) plus new-instruction (terms-of-business-drafter, supplier-booker, listing-copy-writer, particulars-builder, portal-launch-scheduler) and listing-presentation, property-marketing, home-preparation. |
| `loop` | Loop CRM integration — full read/write access to all 8 resource groups: properties, people, viewings, feedback, team, marketing, customer preferences, and supplier operations. Cross-team aggregation with encrypted key storage. |
| `preval` | Single-address pre-valuation pitch pack. One full UK address in, 5-page A4 PDF out (cover, subject valuation, market context, comparables, recently with brand). Consumes the `real-agent-property-data` MCP — no MCP tools of its own. |
| `property-data` | PropertyData UK market analytics — postcode-keyed valuation, asking prices, sold prices, £/sqft baselines and growth, demand (sales + rent), yields, area risk (crime, flood), council tax, planning precedent, UPRN resolution, property-type mix, national HPI. One encrypted API key per account. |
| `vendors` | Active vendor lifecycle management — communication, updates, valuation booking, and offer negotiation. |

All twelve subs are bundle members: they live at `premium-plugins/real-agent/plugins/<sub>/` and register via the resolver's bundle-descent walk. None of them is flat-copied into `platform/plugins/`.

---
# Real Agent Architecture
Source: https://docs.realagent.network/real-agent/architecture.md

# Real Agent Architecture

Real Agent runs on the Real Agent platform — same Pi-on-premise spawn model, same Neo4j graph, same admin/public agent split, same Claude native surface. The `real-agent` premium bundle layers UK estate-agency-specific workflows and connectors on top.

## Three-layer composition

```
Plain-English request
        │
        ▼
Intent router (recognises the workflow)
        │
        ▼
Master workflow (one of five named jobs)
        │
        ▼
Building-block skills (auto-activated, never named to the operator)
        │
        ▼
Connectors (Loop CRM, PropertyData, portals, comms)
```

The operator never picks a skill, never picks a connector, and never invokes a slash command. They describe what they need; the router finds the workflow; the workflow composes the skills; the skills read and write through the connectors.

## Approval gating

Every write action — message send, document e-sign, payment instruction, listing publication — pauses for explicit operator approval before it fires. The pause is presented as a chat-native prompt ("send these three reminders?" with yes/edit/skip per item). No write action ever runs unattended.

Read actions do not pause. The morning round produces a snapshot without asking permission to look.

## Specialist split

Five specialists ship with the bundle. Four (Negotiator, Valuer, Compliance, Listing Curator) are admin-side: they run inside the operator's session and are dispatched by intent. One (Buyer Enquiry) is public-side: it runs in the visitor-facing chat on the agency's published domain.

The admin-side specialists embed domain knowledge from the sub-plugins and operate against Loop CRM, the memory graph, and (for Compliance) live web search for current legislation. The public-side specialist runs on a restricted tool surface and answers only from the agency knowledge base.

## Landing-page hosting

A property's "for sale" landing page is hosted durably on Cloudflare Pages. The `property-brochure` pipeline emits the web bundle as the canonical Pages source tree at `<accountDir>/pages/<slug>/`, and admin deploys it with `cloudflare:site-deploy`; the page answers on `https://<slug>.pages.dev/` and stays up when the install device is offline. The Listing Curator writes that Pages URL to the `:Listing` the public agent renders, so a buyer is always sent to the durable page. This is the durable home for the landing page, replacing the earlier device-tunnel default (`admin:publish-site`), which went dark whenever the device or tunnel was down.

## Per-account customization

Each Real Agent install carries its own customization profile: brand voice, knowledge-base spine, Loop CRM credentials, PropertyData API key, and the agent's territory. Profile changes happen through conversation with the admin agent, not through a settings UI.

---
# Real Agent Workflows
Source: https://docs.realagent.network/real-agent/workflows.md

# Real Agent Workflows

Five named workflows ship with the `real-agent` bundle. Each is invoked by intent — the operator describes what they need and the router selects the workflow.

## Morning Round

**Cadence:** Daily, Monday to Saturday.
**Default delivery:** 07:30, channel per profile (Telegram, web, email).

A daily snapshot composing the diary for the day, awaiting-reply items, overnight enquiry triage, and stalled-deal surfacing — all in one ranked feed. The morning round also includes the chain-progression-tracker output for any deals with active chains.

## Valuation Prep

**Cadence:** Per appointment — a valuation or market appraisal is in the diary.
**Default delivery:** On request, or auto-staged the evening before the appointment.

Comparable-finder, local-market-stats, pricing-scenario-builder, and talk-track-composer compose into a single brief the agent walks into the appointment with. Uses Loop CRM history, PropertyData postcode analytics, and recent sold-prices.

## New Instruction

**Cadence:** Per win — the agent has taken an instruction at a valuation.
**Default delivery:** On request, within 24 hours of the appointment.

Five building blocks turn a verbal instruction into a fully-staged listing: terms-of-business-drafter, supplier-booker, listing-copy-writer, particulars-builder, portal-launch-scheduler. Each write step pauses for operator approval.

## Chase Progression

**Cadence:** Daily (auto, surfaced in morning round) and on-demand.
**Default delivery:** Surfaced in morning round; expanded on request.

Ranks every stalled sale-agreed deal by fall-through risk (risk-scorer), surfaces the next action per deal, drafts tone-matched messages, and pauses on each one for per-message approval. Reuses chain-progression-tracker from the leads sub-plugin.

## Month-End Close

**Cadence:** Monthly — typically first working day after period end.
**Default delivery:** On request or auto-staged on the first of the month.

Period-reconciler reconciles Loop CRM and accounting; commission-calculator runs the splits; payment-batch-stager queues the payments. Operator approves the batch before it fires.

## Single-address pitch pack

Outside the five master workflows, the `preval` sub-plugin produces a 5-page A4 PDF for any UK address (cover, subject valuation, market context, comparables, recently-with-brand). Used as a vendor pitch leave-behind. Consumes the `real-agent-property-data` MCP; no MCP tools of its own.
