A macOS desktop app that makes peer-to-peer AI (mesh-llm) + an agentic chat
(goose) usable by non-technical people. This file is the working design record:
architecture, verified library facts, gotchas, and roadmap. Keep it updated as
the code changes. Sibling project: ../mesh-app (the power-tool/reference
Tauri app this project cites; its DESIGN.md holds deeper mesh/goose API notes).
┌────────────────────────── Mesh.app (Tauri v2) ──────────────────────────┐
│ WebView → http://127.0.0.1:4640 (NO Tauri IPC — plain HTTP only) │
│ │
│ mesh-console backend (axum, src-tauri/src/) │
│ ├── /app/* lifecycle: state, diagnose, host, join, invite, │
│ │ shutdown, reset · /app/events = SSE (phase, downloads) │
│ ├── /app/chat embedded goose agent turn, streamed as SSE frames │
│ ├── /api/* ┐ streaming reverse proxy to the embedded node │
│ ├── /v1/* ┘ (management :3131 / OpenAI :9337) │
│ └── / React UI (ui/dist embedded via rust-embed) │
│ │
│ embedded goose Agent (developer + computercontroller + fetch tools) │
│ └── OpenAI provider → the node's /v1 on loopback │
│ │
│ embedded mesh-llm MeshNode (mesh-llm-sdk, host-runtime daemon) │
│ └── iroh QUIC mesh ⇄ peers │
└─────────────────────────────────────────────────────────────────────────┘
Why no Tauri IPC: tauri-driver has no macOS support, so the entire app
surface lives behind localhost HTTP. Playwright drives the identical frontend +
backend in a plain browser; Tauri is ~30 lines (main.rs) that opens a native
window at the URL and shuts the node down on exit. This is the defining
architectural difference from ../mesh-app (idiomatic Tauri invoke/events,
untestable UI layer).
| Path | What |
|---|---|
src-tauri/src/main.rs |
Tauri shell: start backend thread, open window |
src-tauri/src/bin/mesh-consoled.rs |
headless daemon (same backend, no window) — dev loop + Playwright |
src-tauri/src/server.rs |
axum router: /app/* endpoints + static UI |
src-tauri/src/node.rs |
MeshNode lifecycle: host/join, downloads, invite |
src-tauri/src/agent.rs |
goose Agent: session, provider, extensions, reply→SSE frames |
src-tauri/src/diagnose.rs |
hardware scan → model fit ranking + recommendation |
src-tauri/src/events.rs |
AppEvent enum + ConsoleSink (bridges mesh-llm's global OutputSink → broadcast → SSE) |
src-tauri/src/state.rs |
AppState, Phase (idle → hosting/joining → running), ports |
src-tauri/src/proxy.rs |
streaming reverse proxy to node ports |
ui/src/screens/ |
wizard: Welcome → PowerSetup/JoinFlow → Progress → MeshLive |
ui/src/components/ |
Chat, InvitePanel, MeshViz, ui primitives |
justfile |
just run (build UI + open app), backend, ui-dev, test, check |
~1.6k lines Rust, ~2.4k lines UI.
- mesh-llm: git deps on
Mesh-LLM/mesh-llm, UNPINNED (tracks main via Cargo.lock; currently v0.72.1 @ b4b33ef8). Crates:mesh-llm-sdk(serving),mesh-llm-host-runtime(default-features off → native runtime only, no embedded web console; featuredynamic-native-runtime),mesh-llm-node,mesh-llm-events,mesh-llm-system(hardware detection),mesh-llm-client(model catalog,auto_model_pack). - goose:
aaif-goose/goose(the canonical goose repo, not a private fork) PINNEDrev = "c82c431c"= itsmainHEAD as of 2026-07-03 (goose 1.41.0),rustls-tls, plusgoose-mcpfor bundled MCP servers. Bump the rev deliberately and re-runcargo test— the provider/session surface churns. Gotcha: goose's builtin extension registry starts EMPTY for embedders —agent.rscallsregister_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS). - rmcp: Cargo.lock keeps rmcp AND rmcp-macros at 1.7.0. goose 1.41.0
still does not compile against rmcp 1.8 (
InitializeResult/peer_info()signature change — re-verified 2026-07-03), and rmcp-macros must match rmcp exactly. Re-test before bumping either; don't letcargo updatefloat them. - hf-hub:
[patch.crates-io]→ git branchMesh-LLM/hf-hub#mesh-console/disable-xet-env(fork base + one commit honoringHF_HUB_DISABLE_XET; app sets it by default). REQUIRED for big models: stock hf-hub's xet path stalls on some networks — removing this broke gemma layer downloads (2026-07-02) and was restored same day, as a git dep so no sibling checkout is needed. Verified: 2.5GB Qwen3-4B hosted- answering chat, zero xet in logs.
- npm:
ui/.npmrcpinsregistry=https://registry.npmjs.org/(public). The lockfile was rewritten from Block artifactory URLs — if installs ever fail onglobal.block-artifacts.com, the global~/.npmrcis bleeding through; the per-project file must win.
- The running node is independent from the visible page. Home and Chat share a top navigation bar; opening Home does not call shutdown, and the connection pill returns directly to the live chat.
- The SDK hosts one embedded
MeshNodeat a time. Choosing another public or private mesh while connected therefore opens a confirmation. Confirmation performs one orderly shutdown followed by the requested launch; cancellation leaves the current mesh untouched. Local Goose chats survive mesh switches.
- Host:
MeshNode::builder().serve().model(...)with aNativeRuntimeInstallOptions { progress: callback }so runtime download emitsAppEvent::DownloadProgress. Model download is explicit and BEFORE node start:download_model_ref_with_progress_details(model, true)— byte progress flows through mesh-llm's global OutputSink →ConsoleSink→ SSE. - Join:
share: booldecides.serve()vs.client(); token via.join_token(...). Invite token read back fromnode.invite_token(). - Phases:
idle → hosting/joining (with download events) → running.
- Multiple local chats that survive restarts. goose persists each
conversation to SQLite under
GOOSE_PATH_ROOT; desktop-owned hidden sessions are marked with project idmesh-console, andmesh-console-sessionstores the last active id./app/sessionslists/creates chats, id-scoped activation and history routes switch between them, and/app/chatreceives an explicit session id. A legacy pointer-only session is marked and migrated on first list. Goose remains the canonical transcript store; the WebView only keeps a convenience copy of the selected id. Agent::new(),update_provider(OpenAI provider → node /v1, ModelConfig::new(model)). Extensions: developer + skills (goose-mcp builtins seeded viaregister_builtin_extensions).- A mutex serializes turns; each
/app/chatPOST drives onereply()stream, translatingAgentEvents into SSEFrames (text deltas, tool activity). - Resume on launch:
GET /app/historyflattens the persisted transcript into UI messages (shape_history, unit-tested — same role/content rules as the live translator: assistant text/thinking + tool chips; tool-output user messages dropped). The Chat repaints from it on mount. - New chat + switching: the left chat rail creates a new Goose session and
preserves earlier sessions for selection. Chats can be archived by right-click
or the row archive action, moved into a collapsible History section, and
restored later; Goose's
archived_atremains the source of truth. Only one embedded Agent is loaded at a time; switching while a turn streams is rejected as busy. The legacy/app/new_chatendpoint remains as a compatibility route that creates a new session. Distinct from/app/reset(leave-mesh / error recovery). - Auto-compaction: goose summarizes older turns once the conversation
fills a fraction of the model's context window. We fix that fraction at
0.4 (goose default 0.8) via
GOOSE_AUTO_COMPACT_THRESHOLDininit_process_defaults— small mesh models have modest context and the one long-lived session accretes history. It's env-only: the threshold isn't part of thereply/SessionConfigAPI (goose reads it viaget_param, env over config file;≥1.0would disable it). - Desktop chat always sends the virtual model
auto; concrete model selection is intentionally absent while mesh routing policy is being developed.
mesh_llm_system::hardwarescan (chip, VRAM rating) +MODEL_CATALOGfit ranking (fit_code: model GB vs VRAM GB) → recommended model + full ranked catalog. Unit-tested against upstream thresholds. Not macOS-specific in this crate —mesh-llm-systemhas macOS + Linux paths.
just test— Rust unit tests (diagnose fit ranking etc.).ui: npm run test:e2e— Playwright "mocked": drives the real UI against a mocked backend in a plain browser.npm run test:e2e:real/scripts/run-real-e2e.sh— Playwright "real": the full stack, real node.just check= fmt + lint + test + e2e.- This works precisely because the app boundary is HTTP, not Tauri IPC.
- rmcp 1.7.0 lock pin (see §3) — the single most common accidental break.
- goose builtin registry empty for embedders — without
register_builtin_extensions,ExtensionConfig::Builtinnames fail. - Quit must
_exit, not fall through to AppKitterminate:(issue #8). The embedded ggml/Metal runtime aborts (ggml_metal_rsets_free→ SIGABRT) inside its C++ static destructors when libcexit()runs them at process teardown.main.rs'sRunEvent::Exithandler does a clean node shutdown then callslibc::_exit(0), which skips the destructor phase entirely. Don't remove that hard-exit. - Assistant markdown needs
remark-gfm+.prose-meshCSS (issue #7).react-markdownalone won't parse GFM tables (renders raw pipes), and the emitted tags have no default styling. Both live inChat.tsx/styles.css. - MoA floor: the mesh's virtual model
"mesh"(Mixture-of-Agents) 503s with <2 real models;"auto"works with 1+. (Validated in../mesh-app/src-tauri/tests/model_selection.rs.) Relevant when porting the model-ladder heuristic. - mesh-llm is unpinned: a
cargo updatecan move it; the runtime download is version-coupled (release artifacts + skippy ABI must match — mesh-app's DESIGN.md §3/§7 has the full story if downloads 404 or ABI-mismatch). - Installed app vs source runs:
/Applications/Mesh.app, local bundles, andjust run/just backendshare the native runtime cache and can leave stale helpers. If model startup fails with a macOSdlopenTeam ID / library validation error, verify the launched app hascom.apple.security.cs.disable-library-validationand kill oldmesh-console/mesh-consoledprocesses. Full runbook:docs/development.md. ui/.npmrcvs global~/.npmrc(Block artifactory) — see §3.
- Three themes + system:
dark(absolute black#000, the shipped default),light, andvinyl— the retro-1977 sunset palette fromdocs/index.html(browns/amber/paper, Righteous display font, faint diagonal stripes + film grain viahtml.vinyl body::before/::after). All tokens are CSS custom properties inui/src/styles.css; the theme is a class on<html>(lib/theme.ts, pre-paint mirror inui/index.html).--font-displaypowers thefont-displayutility for headline moments; vinyl swaps it to Righteous (Google Fonts, loaded inindex.html). - No emojis / text glyphs in the UI — iconography is
lucide-reactplus theMeshMarkbrand SVG (components/MeshMark.tsx, optionalpulse). - Chat: assistant turns render with an avatar-style MeshMark, an
ActivityTracepanel (collapsible chain of thought with a live last-thought tail while streaming, plus per-tool rows with spinner/check/fail icons), a blinkingstream-caret, andanimate-message-inentrances. Markdown viareact-markdown+remark-gfm+.prose-mesh(see §6). - Invite links:
mesh://is a registered URL scheme (tauri-plugin-deep-link, config intauri.conf.json). Accepted shapes:mesh://join/<token>,mesh://join#<token>,mesh://join?token=<token>(extract_invite_tokeninmain.rs, unit-tested). Tokens are parked inAppState.pending_inviteand broadcast as aninvite_linknode event; the frontend drainsGET /app/pending_invite(one-shot) on boot and listens for the SSE event, landing on the join flow prefilled. The shareable link ishttps://mesh-llm.github.io/desktop-app/join/#<token>(inviteLink()inInvitePanel.tsx) —docs/join/index.htmlis the static landing page (retro-styled, reads the token fromlocation.hashonly, auto-attempts themesh://bounce, offers copy + download fallback).
Public npm(done, committed).Third startup option: join PUBLIC mesh(done:JoinRequest.public→auto_join_public_mesh(); Welcome card "Try the public mesh" one-click joins with share=true serving the tinyDEFAULT_MODEL— no model decision; Playwright-covered).- Back navigation in the wizard — user can change their mind at any step.
- Downplay model choice — recommended by default; picker behind an "Advanced" disclosure.
- Fastest start — consider mesh-app's tiny-model default
(
unsloth/Qwen3-0.6B-GGUF:Q4_K_M, ~500MB) and/or join-public-as-client first (zero download, chat in seconds) while a local model downloads in background. - Mesh visualisation — MeshViz: who's online / contributing (peer roster in status payload has labels, models, VRAM), plus a nudge to invite/share when solo.
- Port from mesh-app (partially done: smart model default now applies
the ladder — public or ≥3 real models → "mesh"/MoA, else "auto"; picker
offers Auto/Mixture/concrete ids). Still to port: invite-message paste
parsing (
extract_invite_token), the validated owner-allowlist gating (dormant there,tests/gating.rs). - Open-sourcing: sources are clean (no secrets/internal refs); needs a LICENSE file + org decision (remote is squareup/mesh-console).
- Cross-platform: no
cfg(target_os)in this repo; Linux is near-term feasible (mesh-llm ships Linux runtimes); Windows depends on mesh-llm runtime maturity. "Checking your Mac…" is just copy.