MaxyDocs
View as markdown →

Deployment Guide

Hardware Requirements

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

macOS install

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

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

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

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

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

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

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

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

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

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

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

Initial Setup

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

npx -y @rubytech/create-maxy

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

What the Installer Does

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

First admin session — install-time defaults

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

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

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

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:setup-tunnel, 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:

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

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

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

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

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

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

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

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

Pre-publish boot smoke

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

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

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

Service Management

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

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

Browsing the brand filesystem on your LAN (SMB)

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

Remote Access via Cloudflare

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

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

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

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

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

Checking Service Status

Ask Maxy: "Check system status."

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

If Maxy Won't Start

From the Pi directly:

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

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

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

Systemd units on each device

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

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

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

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

Linux laptops: snap-confined Chromium replacement

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

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

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

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

Plugin registration at install time

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

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

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

Generator schema:

FieldSourceNotes
namedirectory name (or PLUGIN.md#name)Used as <name> in plugin install
descriptionPLUGIN.md frontmatter descriptionFalls 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.
  6. For each external plugin with a configureSecret field whose env var is set, pipe /<name>:configure <secret> into a one-shot claude --print invocation. Missing env vars log [plugin-configure] SKIP <name> reason=no-secret-in-env env-var=<NAME> and continue — pairing remains a per-operator manual step.

Brand declarationbrands/<brand>/brand.json#externalPlugins:

"externalPlugins": [
  { "name": "telegram", "marketplace": "claude-plugins-official",
    "configureSecret": "TELEGRAM_BOT_TOKEN", "channelPlugin": true },
  { "name": "discord",  "marketplace": "claude-plugins-official",
    "configureSecret": "DISCORD_BOT_TOKEN",  "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 /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 pathgrep "\[plugin-install\]" ~/.<brand>/logs/install-*.log | tail -50; compare row count against cat brand.json | jq '.externalPlugins | length' plus the on-disk plugin count under <INSTALL_DIR>/platform/plugins/ and <INSTALL_DIR>/premium-plugins/.

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

Running multiple brands on one device

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

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

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

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

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

Version provenance

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

  1. packages/create-maxy-code/package.jsonversion 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:

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

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

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

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

Upgrading

To upgrade Maxy to the latest version, ask Maxy: "Upgrade Maxy." The platform checks the current device identity (hostname and port via system-status), then re-runs the installer with explicit --hostname and --port flags to preserve them across the upgrade.

The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.

Automatic upgrade alert

Maxy checks for new releases on every admin session start — whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.

The upgrade runs inside a live terminal embedded in the Software Update window — you see each installation step stream as it happens, and any password prompts from sudo appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.

The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.