Mycelium V1 — System Architecture (as built)
As-built, not as-designed. This doc describes what the code currently is. For a narrative walkthrough of how it all fits together, see
HOW-IT-WORKS.md. The plan (what we're building + order) lives inV1-BUILD-SPEC.md; the running build journal lives in theV1-BUILD-HANDOFF-*.mdfiles. Kept current via theliving-docsskill. Status markers: ✅ built+verified · ◑ partial · ⚠️ Tier-2/gated · ⬜ planned.
1. What it is
A self-hosted, single-user MCP server — a private "cognitive vault." It ingests a person's messages/documents, encrypts everything at rest, embeds + enriches it locally, and serves it back to an MCP client (Claude Desktop/CLI/ mobile) through tools. No multi-tenancy, no autonomous agent loop — it's a pure tool server (decision D5).
2. Process model & run modes
One Node entry point, src/index.js, selects a mode:
| Mode | Command | Surface |
|---|---|---|
| MCP stdio (default) | npm start |
MCP over stdio for a local client |
| MCP Streamable HTTP | npm run start:http (--http / MYCELIUM_HTTP=1) |
remote MCP + OAuth — binds 127.0.0.1 only; reached over the internet via the TLS-passthrough relay (Caddy terminates TLS on this Mac) |
| REST | npm run rest |
REST over the shared handler map |
| Portal (UI + REST) | npm run portal |
portal at / + REST /api/v1/*, localhost-only |
| Enrichment service | npm run start:enrich (--enrich) |
the :8095 background enricher |
Portal UI — two coexisting. The canonical SvelteKit app (portal-app/,
the real production UI) is served at / once built (npm run portal:build →
portal-app/build, a static SPA); the Node server (src/server-rest.js,
resolvePortal()) auto-detects it, serves it with SPA fallback to 200.html,
and otherwise falls back to the single-file portal/index.html. Override with
MYCELIUM_PORTAL=canonical|legacy|auto. The canonical app's data layer
(portal-app/src/lib/api.ts) calls cloud /portal/* paths, rewritten to
/api/v1/portal/* and served by a compatibility surface (three routers,
below) that returns the exact shapes the screens expect, backed by the local db.
The primary nav is the honest V1 set (Mindscape · Library · Import · Timeline ·
Profile · Settings) + a disabled "Coming later" group; screens with no V1 data
source degrade to a graceful empty state. Best iterated visually on the Mac
(npm run portal:dev). See portal-app/README.md and
UX-COMPLETE-DESIGN-2026-06-01.md.
A native Mac shell (src-tauri/, Tauri v2) wraps the portal: it spawns the
Node server and opens a window at http://127.0.0.1:8787 (so it shows whichever
portal the server serves — build portal-app to get the canonical UI). Portals
are verified by verify:portal (single-file) + verify:portal-serve
(canonical serving); the Rust shell is built on the Mac (src-tauri/BUILD-MAC.md).
Two sidecar services run as their own processes:
:8091embed-service — Nomic v1.5 ONNX embeddings (pipeline/embed-service.py), ⚠️ Tier-2 (needs onnxruntime/model installed).:8095enrichment — embed-on-write + NLP drain (src/enrich/server.js). ✅
The channel daemon (:3010, packages/channel-daemon/, Telegram/Discord bridge) is a third supervised process — owned by src/channels/supervisor.js (mirrors the embed supervisor: adopt-or-spawn, backoff-restart, health) and started only when CHANNEL_ENABLED + a bot token is set. It is keyless: it reaches vault plaintext only over loopback — the vault REST (:8787) for config/context and a loopback-only MCP endpoint (:8787/internal/mcp, src/mcp-loopback.js, strict-loopback 403-gated, never public) for the agent turn's tools incl. the reply egress tool (wired when AGENT_URL is set). Its reply still flows agent → AGENT_URL → the daemon's loopback egress chokepoint → Telegram. packages/ is staged into the app bundle (scripts/build-app-bundle.sh), so this all works in the packaged app with no manual steps. ✅
Inbound media (photos / documents / voice notes, Telegram): the daemon downloads the file (memory-only, AFTER authorization, ≤20MB Bot-API cap — packages/channel-daemon/media.js), POSTs raw bytes to the vault's encrypted blob store (POST /api/v1/upload → src/ingest/blob-store.js, AES-GCM fail-closed), then asks the vault for turn-visible text via POST /api/v1/internal/attachment-context — image→LOCAL vision caption (src/enrich/describe-image.js, capability-picked model via src/enrich/model-caps.js), voice/audio→LOCAL transcription (src/enrich/transcribe-audio.js, OpenAI-compat input_audio against Ollama), text→utf8 decode, pdf/docx→src/enrich/extract-document.js (unpdf/mammoth). Derived text is stored encrypted (attachments.description|transcript) and folded into the captured message content, so embedding + the agent turn see it with no backend changes. Every step fail-soft (placeholder lines, never blocks the text turn). Zero egress: extraction runs on-box. ✅ (design: docs/CHANNEL-INBOUND-MEDIA-DESIGN-2026-06-10.md)
3. Components
| Component | Path | Status |
|---|---|---|
| Entry point / mode switch | src/index.js |
✅ |
| MCP server (tool registration) | src/mcp.js |
✅ |
| Streamable HTTP transport | src/server-http.js |
✅ |
| REST surface + file upload | src/server-rest.js, src/api.js (/api/v1/upload) |
✅ |
| Canonical portal (SvelteKit) | portal-app/ → npm run portal:build (served by REST) |
✅ builds + served; core screens wired |
Portal compat surface (/api/v1/portal/*) |
src/portal-compat.js (Library/Timeline/Profile/Settings/onboarding), src/portal-mindscape.js (3D scene + panels), src/portal-uploads.js (import: multipart + chunked) |
✅ |
| Local auth-shim (no login wall) | src/auth-shim.js |
✅ |
| Import parsers (Claude / ChatGPT) | src/ingest/import-parsers.js |
✅ (Obsidian/LinkedIn ⬜) |
| Generate-mindscape trigger (clustering job) | src/jobs.js + POST /api/v1/portal/mycelium/generate |
✅ (job lifecycle; real run ⚠️ Tier-2) |
| Chronicle narration | pipeline/describe-chronicles.js (run-clustering stage) |
✅ (logic; real model ⚠️ Tier-2) |
| Local portal (single-file SPA) | portal/index.html (REST fallback) |
✅ |
| Native Mac shell (Tauri) | src-tauri/** |
◑ scaffold (build on Mac) |
| OAuth 2.1 + PKCE (better-auth) | src/auth.js |
✅ |
| D1/SQLite storage adapter | src/adapter/d1.js |
✅ |
| DB namespaces (per table) | src/db/*.js |
✅ |
| Migration runner | src/db/migrate.js + migrations/000*.sql |
✅ |
| Scope-partitioned crypto (two-key vault) | src/crypto/crypto-local.js, src/crypto/keys.js, src/crypto/guardians/* |
✅ |
| Master-key source (env / macOS Keychain / 1Password) | src/crypto/key-source.js, scripts/set-keys.mjs |
✅ |
| Account keystore (single recovery key; SYSTEM_KEY HKDF-derived) | src/account/keystore.js, src/account/keychain-names.js |
✅ (#36) |
| First-run ceremony + restore + re-view (setup-mode) | src/account/router.js (/api/v1/account), portal-app/src/routes/setup/ |
✅ (#36) |
Vault backup (encrypted .myvault snapshot) + restore-from-backup; /restore hardened (no silent empty vault); needsRecoveryKey state |
src/account/backup.js, src/account/router.js (GET /backup, POST /restore-backup), portal-app/src/routes/setup/ + SettingsView.svelte · verify:backup |
✅ (design) |
| Externally-served vault (client opens a vault on another machine over the relay) | design only — relay = ciphertext passthrough; key never leaves the serving machine; couples to src/remote/ + mycelium-managed/ |
📐 design (V2-adjacent) |
| Data location (durable per-OS dir; survives updates) | src/paths.js (MYCELIUM_DATA_DIR) |
✅ (#36) |
| Embeddings client + search adapter | src/embed/client.js (→ :8091), src/search/embedder.js (createServiceEmbedder) |
✅ (real vectors ⚠️ Tier-2) |
| Inference router (local Ollama + BYOK cloud) | src/inference/{router,local,cloud,errors}.js |
✅ (real models need Ollama/keys) |
Model-aware sizing (profiles + token budgeting; auto num_ctx/max_tokens per model) |
src/inference/{model-registry,model-profile,token-budget}.js · verify:model-sizing |
✅ (design; local probe live-smoked) |
Native local chat adapter (Ollama /api/chat, sized num_ctx) + token-budgeted preamble |
src/agent/harness.js (ollamaNativeAdapter), src/portal-chat.js · verify:harness-local |
✅ (live-smoked) |
| Token-usage accounting (counts by source/area/provider/model; no content) + transparency UI | src/db/llm-usage.js, src/inference/usage.js, src/portal-usage.js (GET /portal/usage), portal-app/.../settings/UsageSection.svelte · migrations/0014 · verify:usage |
✅ (live-smoked) |
| Search (BM25 + vector + RRF fusion) over messages + documents + topology profiles | src/search/** · verify:search |
✅ (documents BM25-only — see below) |
| Topology / AnalysisEngine pipeline | src/topology.js, src/topology/helpers.js, pipeline/ |
✅ (real run ⚠️) |
| Ingestion choke-point + uploads | src/ingest/{capture,upload,blob-store,enqueue}.js |
✅ |
| Enrichment service (embed + NLP) | src/enrich/{service,server,extract}.js |
✅ |
| MCP tools (36 across 17 domains) | src/tools/*.js |
✅ |
| Box identity (ed25519 from master key) | src/identity/identity.js |
✅ |
| Publishing: signed links + fail-closed public server | src/publish/{links,public-server}.js |
✅ (custom-domain; mycelium.id handle = central infra, planned) |
| Mind-files subsystem | src/mindfiles/mind-files.js |
✅ |
4. Data flow — capture → searchable
client/connector
│ captureMessage / /ingest (the single ingestion choke-point)
▼
src/ingest/capture.js ──► messages row (content encrypted at rest)
│ fire-and-forget nudge (src/ingest/enqueue.js)
▼
:8095 enrichment service (src/enrich/server.js)
├─ Stage 1 drainOnce nlp_processed 0 → 2 embed via :8091, store vector envelope
└─ Stage 2 enrichNlpOnce nlp_processed 2 → 1 extract entities/tags/summary (rules)
▼
search (BM25 + vector, RRF fusion) + getContext preamble (D5)
▼
back to the client as tool results
Ingest surfaces & volume: raw files upload to /api/v1/upload (raw bytes,
dependency-free) → encrypted blob → attachment → message → enrich. Bulk history
via importMessages. The portal Import screen posts AI-export archives
(Claude / ChatGPT .zip) to /api/v1/portal/upload[/chunk|/complete]
(src/portal-uploads.js, multipart via busboy, single-shot + chunked assembly);
they're parsed (src/ingest/import-parsers.js) and funneled through
captureMessage. The same surface also accepts a canonical-Mycelium vault
export (manifest.json format:'mycelium-vault-export', v3/v4 — the
bring-your-vault-home path): src/ingest/vault-import.js restores ~40 table
families through the auto-encrypting adapter (db.rawQuery), re-encrypts
attachment binaries via putBlob, re-encrypts nomic clustering vectors under
the V1 key (encryptVector; foreign-key ciphertext is dropped + reported),
resets messages to nlp_processed=0 for local re-embedding, and reports skipped
families (agents fs, ai_providers, connections, passkeys, secrets) — design
docs/VAULT-IMPORT-FROM-CANONICAL-DESIGN-2026-06-10.md, gate verify:vault-import.
The untrusted-file path is hardened (decompression-bomb cap
with streaming abort, bounded in-memory assembly, no archive-path writes, no
content leakage) — see verify:import-security. Limits: MYCELIUM_API_BODY_LIMIT
(64mb JSON), MYCELIUM_UPLOAD_LIMIT (256mb raw), MYCELIUM_IMPORT_LIMIT_BYTES
(512mb per import).
Query embedder wiring: boot() (src/index.js) auto-wires the query-time
embedder via resolveDefaultEmbedder() → createServiceEmbedder() (an adapter
that bridges the embed client's positional-task signature to the search
embedder's {task} contract, and reports unit:true since the embed-service
L2-normalizes). The backend fail-softs to BM25 per query when :8091 is down.
Opt out with MYCELIUM_DISABLE_EMBED=1; redirect with MYCELIUM_EMBED_URL.
Indexed layers (src/search/d1-loader.js SOURCES): messages (with stored
embedding_768 → ANN+BM25), the three topology profile tables (kind-prefixed
territory:/realm:/theme:), and documents (document:-prefixed,
BM25-only). Documents carry no stored embedding (enrichment embeds messages
only), so they load with skipEmbed — indexing them with a live embedder would
fire one :8091 call per doc at cold start (the freeze PIPELINE-INTEGRITY fought).
bulkSearch partitions ranked hits back into the 5 mindscape layers and hydrates
each; documents/internal/forgotten/sensitive are filtered at load and hydrate
(defense in depth). Semantic (ANN) document ranking = deferred Phase 2 (embed docs
in the enrich pipeline). Design: docs/DOCUMENT-SEARCH-DESIGN-2026-06-17.md.
Enrichment state machine (faithful to the canonical model): 0 unprocessed → 2 embedded → 1 enriched → -1 failed. The NLP pass (src/enrich/extract.js)
is a pure deterministic rules extractor (url/email/money/date/proper-noun/
hashtag + keyword tags) behind a seam a model-backed pass can replace.
4b. Persona-Claims (PersonaTree adoption — Tier-3)
A lifecycle layer that turns interaction history into durable, evidence-grounded
person-level claims (values, principles, identity, personality, hard
boundaries) and tracks how confidence in each claim moves over time. Design:
docs/PERSONA-CLAIMS-DESIGN-2026-06-06.md.
cadence heartbeat (src/claims/heartbeat.js, REST, zero-LLM)
│ on a day/week/month/quarter window roll-over (and no clustering job live)
▼ spawns
pipeline/discover-claims.mjs (child, builds the local-first inference router)
│ gather window evidence (messages) ─► propose claims (infer, sensitive:true)
│ ─► identity-match (embedding cosine ≥0.62, content_hash; lexical fallback)
│ ─► validate support/conflict (sensitive:true) ─► log-odds confidence update
▼
person_claims (current) + person_claim_snapshots (per-window trajectory)
▼ surfaced by
getContext "## WHAT YOU'VE LEARNED ABOUT THEM" · searchMindscape (routed to
claim level) · personaClaims MCP tool · portal /claims (ClaimsView + TimeSeries)
- Modules:
src/claims/{confidence,validator,support-path,route,discovery,windows,heartbeat}.js,src/db/claims.js(db.claims),pipeline/discover-claims.mjs,src/portal-claims.js,src/tools/claims.js,portal-app/.../ClaimsView.svelte. - Confidence: log-odds with type-specific decay (boundary λ=0 — allergies/trauma never fade; mood τ≈1wk). Identity-match uses embedding cosine (Nomic, threshold 0.62 calibrated live) so paraphrases across cadences merge into one row; rejected claims are tombstoned (never resurrected).
- Security: every discovery/validator model call is
sensitive:true→ router hard-blocks US-cloud egress, runs on-box. All claim content/confidence/type/snapshots encrypted at rest. Tier-3: no local model → discovery is a logged no-op (fail-open). - Verification:
verify:claims,verify:claims-discovery,verify:claims-rest+ 53 unit tests; Tier-3 live-validated against Ollama+Nomic; portal live-rendered.
5. Storage & schema
- Engine: better-sqlite3 with a D1-compatible adapter (
src/adapter/d1.js), so the same code runs on Cloudflare D1 later. - Schema: all V1 tables ported in
migrations/0001_init.sql; later numbered migrations add columns/tables (e.g.0002attachments.local_path;0011person_claims+person_claim_snapshotsfor Persona-Claims). Applied in lexical order every boot bysrc/db/migrate.js(idempotent). - Blobs: uploaded files encrypted to a local blob store (
src/ingest/blob-store.js). - Entity change-log (
entity_snapshots, migration 0013,src/db/history.js, designdocs/ENTITY-HISTORY-DESIGN-2026-06-11.md, gateverify:history): append-only version history of each territory/realm's narrative (name/essence/chronicle — hooked at the describe write sites) and dynamics (energy/coherence/velocity/counts —pipeline/snapshot-entities.js, every Generate). Describe otherwise upserts in place, losing the past.db.history.recordSnapshotdedups vs the latest version (decrypt-and-compare — no plaintext content hash) and appendsseq+1only on real change; rows persist after the entity dissolves/prunes. The singlepayloadJSON blob carries content AND all metadata (stage/model/version/ capture timestamp) and is the only data column — everything that describes the user or its timing is encrypted; the rest is the row-addressing skeleton (entity_kind/id, snapshot_kind, seq) that can't be encrypted without losing WHERE/ORDER/UNIQUE (non-deterministic AES-GCM) and carries no content. Structural/metric change-over-time is already logged elsewhere (cluster_events,territory_lineage,*_snapshots,person_claim_snapshots); the unified per-entity timeline is a deferred UNION-at-read, not a copy (avoids a second source of truth). Read surfaces (portal panel, MCP tool) deferred — storage only for now. - Realms lifecycle: territories dissolve (
dissolved_at, lineage preserved); realms have no lineage, so re-cluster prunes realm rows with no liveclustering_points(pipeline/cluster.py) and the Describe pass maintainsrealms.territory_count/message_countfrom live points (pipeline/describe-clusters.js; plaintext ranking keys).cognitive_metrics_per_territorytopology_metricsare 0-row by design until a canonical v4 import or the spec'd topology-graph family lands — kept as restore targets, NOT vestiges (audit:docs/MEASUREMENT-DEADWEIGHT-AUDIT-2026-06-10.md, gateverify:realm-prune).
- Describe management (
docs/DESCRIBE-MANAGEMENT-DESIGN-2026-06-11.md, gateverify:describe-gating): the naming pass skips unchanged clusters via a plaintextdescribe_input_hash(SHA-256 over sampled message UUIDs + point count — never content-derived) and never clobbers a real name with a placeholder (failed narration on an unnamed cluster leaves hash NULL → retried every run). Chronicles re-narrate on drift (message_countvspoint_count_at_description, ratio ≥1.5 + Δ≥10, env-tunable) — not only on version bumps; realms get chronicles too (UPDATE-only viadb.mindscape.upsertRealmDescription;raw_responsedeliberately not stored — plaintext column). Dominant successors inherit the dissolved predecessor's chronicle in cluster.py (ciphertext-verbatim copy, aftercompute_dynamics); drift re-narrates them as content diverges.src/jobs.jsrefreshes the in-RAM search index after Generate + chronicle completion (mind-search registry; stored vectors rehydrate). The corpus build is single-flight (one in-flight build promise; concurrent searches await it rather than each starting a fresh full-vault load) and is warmed in the background at boot (searchHelpers.warm()inserver-rest.js, real-app block) so the first search joins an already-running warm instead of eating the minutes-long cold build — PR #232. Narration always uses the user's ACTIVE provider +model_preference(sameresolveInferenceConfigas chat);generation_modelrecords the real narrator label. - Location (#36): the vault lives in a durable per-OS data dir (
src/paths.js→~/Library/Application Support/id.mycelium.appon macOS, set by the Tauri shell asMYCELIUM_DATA_DIR), so app updates don't wipe history. A legacy in-repo./datavault is non-destructively relocated on first boot. A fresh vault self-migrates (no separateinit-db). ⚠️ A hand-rolled stdio MCP config (node src/index.js) must setMYCELIUM_DATA_DIRto the same dir, else it opens a different, empty vault.
6. Security model
- Single recovery key (#36, amends D4 + D6): the user saves only
USER_MASTER(64-char hex);SYSTEM_KEYis HKDF-SHA256-derived from it (src/account/keystore.js). No BIP-39. Per-key KCV still guards typos; both keys land in the Keychain so the boot/unlock path is unchanged. The keys are no longer independent (accepted: SYSTEM_KEY only encrypts the normally-empty operatorsecretstable). A lost key is unrecoverable by design — so creation forces a save-it gate, and the key is re-viewable in Settings / restorable by paste. - Key source (
src/crypto/key-source.js,MYCELIUM_KEY_SOURCE): the two hex keys are read at boot fromenv(default), the macOS Keychain, or 1Password (op). Keychain/1Password keep keys out of shell history and config files (and out of the process env until unlock). Shell-injection-safe (execFilearg arrays), fail-closed, never logged.npm run set-keysprovisions. KCV (above) stays as the integrity interlock regardless of source. - Envelope encryption: AES-256-GCM wrapped-DEK (
src/crypto/crypto-local.js).ENCRYPTED_FIELDSare encrypted/decrypted transparently by the adapter on write/read — callers handle plaintext, storage holds ciphertext. - Keys are memory-only after unlock; never in env/DB/logs/HTTP.
- Fail closed: missing key → refuse to write; missing auth → reject.
- Embeddings are sensitive — stored as ciphertext envelopes, treated like plaintext.
- Full principles in
../CLAUDE.md§"Security first".
7. Transports & auth
stdio (local client), Streamable HTTP (
src/server-http.js), and a REST surface (src/server-rest.js) all dispatch through one shared handler map, so a tool is written once.OAuth 2.1 + PKCE via better-auth (
src/auth.js) guards the HTTP surfaces.Federation (Tier-0 + 0b) — inter-instance connect, gated by signature not OAuth: the box ed25519 identity (
src/identity/identity.js) is published asGET /.well-known/did.json(did:web:<publicHost>) +GET /.well-known/webfinger.POST /federation/connectaccepts a signed connect-request → pending connection;POST /federation/connect-responsecarries the accepter's signed callback that flips the requester's "Sent" → "Connected" (the bilateral handshake). Both verify the sender'sdid:webkey + freshness + nonce-replay. Protocol lives insrc/federation/{sign,did,handlers,router}.js; the social graph + signed-outbound +receiveRemote/respondRemote/receiveResponselive insrc/db/connections.js(wired ingetDb). User surfaces: the 3 MCP tools (src/tools/federation.js) and the Connections page (portal-app/.../connections, promoted to a live nav item with a pending-request badge) backed by/portal/connections/*(src/portal-compat.js). Fails closed with no public host (did.json 404, connect 503).Phase B (Matrix/Megolm shared spaces) — the live cross-node E2EE delivery layer, built mock-complete (B1–B10), live wiring (B11) deferred to a deploy session (
docs/DEPLOY-federation-phaseB-B11-HANDOFF-2026-06-06.md). One shared space ⇄ one Megolm room (space_matrix_rooms,migrations/0011). A share grant drives room membership (src/federation/space-sync.js: lazy room create → invite; revoke → kick), local knowledge mirrors out through the egress chokepoint (src/federation/matrix-egress.js: allowlist + encryption-required gates, sha256-only audit — §11), and inbound records are validated (src/federation/lexicon.js, + §7 vector tripwire) and persisted once. All of it sits behind an injectableMatrixClientseam (src/federation/matrix-client.js) so it's unit-tested without a homeserver; the deploy session swaps in the real matrix-js-sdk client (A1b-proven, 7/7). Seedocs/DESIGN-federation-phaseB-BUILD-PLAN-2026-06-06.md.
8. Ports
| Port | Service | Status |
|---|---|---|
:8091 |
Nomic embed-service (Python) | ⚠️ Tier-2 |
:8095 |
enrichment service (Node) | ✅ |
:3010 |
channel daemon (Telegram/Discord bridge, loopback) — supervised, keyless | ✅ |
:8787/internal/mcp |
loopback-only MCP for the channel daemon's agent turn (incl. reply) |
✅ |
| HTTP/REST | configurable (MCP HTTP + REST) | ✅ |
9. Verification
npm run verify runs 29 GO-gated suites (scripts/verify-*.mjs), each with
a PASS/FAIL ledger + VERDICT line: foundation, mcp, mindfiles, metrics, rest,
search, topology, embed, oauth, context, ingest, blob, enqueue, enrich,
keysource, account (#36 — setup/restore/recovery-key + single-key derivation;
skips cleanly with no Keychain), portal, portal-serve, portal-data,
portal-mindscape, import, import-timestamps, import-security, portal-tps, generate,
chronicles, integration, nav, inference, publish. CI
(.github/workflows/verify.yml) runs them on every PR. Tier-1 suites pass
without the ML stack; Tier-2 parity (real embeddings/clustering) is verified
on a host with onnxruntime/Ollama installed. Portal/SPA-dependent checks SKIP
cleanly when portal-app/build is absent (as in CI).
10. Built vs planned (vs the spec)
✅ Built + verified: D1 adapter, MCP server (stdio), HTTP + REST transports,
OAuth 2.1, two-key vault encryption, search, topology pipeline, getContext (D5),
ingestion + encrypted uploads, full enrichment pipeline (embed + NLP rules),
query embedder wiring, master-key source (env/Keychain/1Password + set-keys),
inference router (local Ollama + BYOK cloud, opt-in egress), 36 tools, local
portal UI (capture/search/mindscape/tasks + tools console). Canonical portal
build-out: tight nav + "Coming later"; Mindscape read surface (3D scene
aggregator + panels); Claude/ChatGPT import (single-shot + chunked, hardened);
Timeline/Profile/Settings; first-run welcome — all behind their own verify suites.
⚠️ Built, Tier-2-gated: real Nomic embeddings + clustering (need onnxruntime/ Ollama on the host); inference router's cloud path needs a BYOK key, its local path needs Ollama running.
◑ Scaffolded (build on Mac): native Tauri shell (src-tauri/) — wraps the
portal into Mycelium.app; Rust built on the Mac per src-tauri/BUILD-MAC.md.
⬜ Planned / not yet built: agent templates, the Tauri native first-run
key-setup ceremony (designed — UX-COMPLETE-DESIGN §5 — Mac/Rust build pending),
profile editing (PUT /portal/profile — needs a profile store), the
/mindscape/explore territory-description job, Obsidian/LinkedIn import.
Remote-connect transport is now built (TLS-passthrough: bundled frpc+caddy,
key-on-Mac ACME via acme-dns, loopback --http, + the open-source mycelium-managed/
control-plane — see REMOTE-CONNECT-TRANSPORT-DESIGN + REMOTE-CONNECT-MANAGED-DESIGN;
standing up the live relay/DNS/acme-dns/LE infra is the operator's deploy). The control-plane
now carries the onboarding/relay-billing layer (DESIGN-onboarding-and-relay-billing-2026-06-05):
a public_key-keyed entitlement table (O3) and an opt-in, fail-closed Turnstile bot-gate
on /v1/challenge (O2, mycelium-managed/src/turnstile.js; secret env-only, single-side
verification — the nonce carries the proof to provision; verify:turnstile GO). The app's
connect widget renders Turnstile in a cross-origin iframe served by the control-plane
(GET /turnstile), so Cloudflare's script runs in the control-plane origin and never in the
vault portal — only the solved token postMessages back (browser smoke pending). Billing
(O4/O5, mycelium-managed/src/billing.js — no SDK, REST + node:crypto) adds a reserve-then-pay
gate: an unentitled /v1/provision holds the handle and returns 402 {checkoutUrl} before any
cert side-effect; a fail-closed POST /v1/stripe/webhook (raw-body HMAC verify) flips
paid_until. Opt-in (off without MYC_STRIPE_SECRET → free); verify:billing + verify:provision GO. (The in-app
"generate mindscape" trigger + chronicle narration are also built — see the
component table.) See
V1-BUILD-SPEC.md §"What's left".
Harness Connect — "pick your harness" surface (DESIGN-harness-connect-2026-06-06):
a curated card menu over the two doors (North memory :4711/mcp, South model :4711/v1)
so both UI users (click) and devs (copy) can connect any agent harness. As-built:
portal-app/.../settings/HarnessPickerSection.svelte (Settings card, above
ConnectYourAISection) + per-harness recipes in docs/HARNESS-RECIPES.md (Mycelium-native ·
Claude Desktop/Code · opencode · openclaw · hermes-agent · custom), config keys verified
against each project's docs. openclaw carries the scam-safety note. UI + docs only — no
new backend, no auth change (reuses the shipped static-bearer + OAuth + :4711/v1 gateway);
remote stays "coming soon" until the relay is live. verify:harness-connect GO (8 checks).