Target: turn ZenNotes into a progressive web app (PWA) that can also be self-hosted on a home server and driven entirely from a browser, without losing what makes ZenNotes ZenNotes — keyboard-first editing, vim motions, plain Markdown files on disk, and first-party MCP access.
This is a design doc, not an implementation plan. It describes the architecture, the deployment modes we want to support, and the phased porting path from the current Electron build.
- A browser-accessible ZenNotes that keeps full parity with the desktop build for editing, navigation, search, tasks, and rendering.
- Installable as a PWA (add-to-home-screen, standalone window, offline).
- Self-hostable on a home server via Docker, one volume, one port, no database setup required.
- Vault is still plain Markdown files on a filesystem. No migration, no lock-in, no proprietary store.
- MCP server keeps working against the same vault, so Claude Desktop / Claude Code / Codex etc. still read and write user notes safely.
- Same keyboard model in the browser as on desktop: vim mode, leader flows, command palette, which-key hints, buffer switching.
- Real-time multi-user collaboration (CRDTs, presence cursors).
- A hosted SaaS offering run by the ZenNotes project. (We design so it is possible later; we don't ship it now.)
- Mobile-first UI. Mobile support is nice to have, but the primary web target is a desktop browser on a laptop or workstation.
- Replacing the Electron build. The desktop app stays, and users can run both against the same vault.
- The renderer (
src/renderer/) is a full React SPA with Zustand, CodeMirror 6, and the unified markdown pipeline. All of that is browser-compatible already. The porting work is almost entirely in the main/preload layer. - The backend is a new Go service, not a reused Node/Electron
module.
src/main/vault.tsis the reference behavior — the Go service implements the same operations, validated against the same test fixtures. This trades "share one vault module" for the things Go buys us on a home server: single static binary, cold-start in milliseconds, tiny memory footprint, trivial multi-arch cross-compile, and aFROM scratchcontainer image. - MCP is part of the product, not an accessory. Any deployment story must keep the MCP server reachable against the same vault.
ZenNotes Web supports four deployment modes. They share the same client bundle and, for three of them, the same server binary.
A single Docker container (or single binary) runs on a home NAS, Mac
mini, Raspberry Pi, or VPS. The user mounts a vault directory into the
container and points a browser at https://notes.home.lan (or a
Tailscale/Cloudflare Tunnel hostname). This is the deployment the
"users who want to run this on their home server" request maps to.
- One container, one volume, one port.
- Single-user auth out of the box (bearer token in a cookie).
- Reverse proxy (Caddy) handles TLS so PWA features work.
- Optional sidecar MCP container shares the same vault volume.
For users who want zero backend and only their own browser, we ship a "local vault" mode that uses the browser's File System Access API. The user clicks "Open vault", picks a directory, and the PWA reads and writes Markdown files directly through the browser.
- Chromium-only, desktop-only.
- No network traffic, no install, no account.
- Good entry-level mode and a useful fallback if the home server is down.
- MCP is not available in this mode (no Node process on the box).
Same container as Mode A, but with per-user vault dirs and session auth (passkeys or argon2-hashed passwords). Useful for families or small teams running a single home server. Off by default.
The same Go binary, run by us (or anyone) as a managed multi-tenant
service. Users sign up with email or passkey, pay via Stripe, and get
a vault at https://app.zennotes.io without running anything
themselves. This serves people who want the product but won't
self-host.
- Same server binary as Mode A, with
--mode=saasplus a control plane (see §11). No second codebase. - Per-tenant vault directories under a single storage root.
- Managed auth (email + passkey + OAuth), billing, email, backups.
- MCP access remains available for paid tenants via a per-tenant scoped HTTPS tunnel.
- Shipping Mode D is in scope for this phase, not deferred. Detailed architecture in §11.
┌──────────────────────────┐ ┌──────────────────────────┐
│ Browser │ │ Home server host │
│ │ │ │
│ ┌────────────────────┐ │ HTTPS │ ┌────────────────────┐ │
│ │ ZenNotes PWA │◄─┼─────────┼─►│ zennotes-server │ │
│ │ (React renderer) │ │ WS │ │ (Go, static bin) │ │
│ │ │ │ │ │ │ │
│ │ CodeMirror 6 │ │ │ │ chi + net/http │ │
│ │ Zustand store │ │ │ │ fsnotify watcher │ │
│ │ Service worker │ │ │ │ SQLite FTS index │ │
│ │ IndexedDB cache │ │ │ │ embedded PWA │ │
│ └────────────────────┘ │ │ └─────────┬──────────┘ │
│ │ │ │ │
└──────────────────────────┘ │ ┌─────────▼──────────┐ │
│ │ Filesystem vault │ │
│ │ /vault/ │ │
│ │ inbox/ │ │
│ │ quick/ │ │
│ │ archive/ │ │
│ │ trash/ │ │
│ │ attachements/ │ │
│ └─────────▲──────────┘ │
│ │ │
│ ┌─────────┴──────────┐ │
│ │ zennotes-mcp │ │
│ │ (sidecar) │ │
│ └────────────────────┘ │
└──────────────────────────┘
Three moving pieces:
- Client — the existing renderer, unchanged in structure. All the
Electron-specific code paths are hidden behind a single interface
(
VaultBridge, see §6). The client never talks to Node APIs directly; it talks to a bridge. - Server — a Go binary (
zennotes-server) that exposes the same vault operations the Electron main process implements today, but over HTTP and WebSocket instead of IPC. Ships as a single static executable; Docker image isFROM scratch. - MCP sidecar — the existing standalone MCP server from
src/mcp/(Node), reachable against the same vault. Shipped as a separate container so it can be disabled independently, and so we can keep using the upstream TS MCP SDK without re-porting it to Go.
Maps cleanly against src/main/ (replace) and src/renderer/ (keep).
| Area | Today (Electron, TS) | Web version |
|---|---|---|
| Window lifecycle | BrowserWindow, app menu |
Browser handles it |
| Preload IPC bridge | src/preload/index.ts |
VaultBridge + fetch client |
| Vault I/O | src/main/vault.ts (Node fs) |
Go port in server/internal/vault, behind HTTP handlers |
| File watcher | chokidar (Node) | fsnotify (Go), piped over WS |
| Asset protocol | zen-asset:// via protocol.handle |
GET /api/assets/* |
| Native file picker | dialog.showOpenDialog |
File System Access API (Mode B) or server-side config screen (Mode A) |
| Auto-updater | electron-updater + GitHub releases |
Service worker update + container image tag |
| Zoom / window state | BrowserWindow.setZoomFactor |
Browser zoom, CSS zoom if needed |
| Global shortcuts | globalShortcut.register |
Only in-tab; keep in-app bindings |
| Deep links | zen:// custom protocol |
https://.../open?path=... |
| Notifications | Node Notification |
Web Notifications API |
| Menus | Native Menu |
In-app command palette (already exists) |
| MCP server | Bundled Node, spawned over stdio | Separate container (still Node), same code |
Everything under src/renderer/ is already browser-safe: CodeMirror 6,
unified/remark/rehype, KaTeX, Mermaid, JsxGraph, Tailwind, Zustand,
DOMPurify, Fuse.js. The port does not touch any of that.
- Language: Go 1.22+. Chosen for static binaries, fast startup,
low idle memory (critical on a Raspberry Pi or small VPS), trivial
cross-compilation for amd64/arm64, and a
FROM scratchDocker image. - Router:
go-chi/chion top ofnet/http. Idiomatic, stable, composable middleware, no framework lock-in. We deliberately avoid non-stdlib-compatible frameworks (Fiber/fasthttp) so every standard middleware and observability tool works. - WebSocket:
coder/websocket(formerlynhooyr.io/websocket). Context-aware, modern API, cleaner thangorilla/websocket. WS is preferred over SSE because the client already needs bidirectional channels for future features (collaboration, command streaming). - Filesystem:
os/io/fs/path/filepath(stdlib) for vault I/O,fsnotify/fsnotifyfor watching. - Search index: SQLite with FTS5 via
modernc.org/sqlite(pure-Go, no CGO). Rebuilt on startup, maintained incrementally by the watcher. Stored at/vault/.zennotes/index.db. For small vaults we skip it and fall back to an in-memory index. - Markdown parsing (server-side):
yuin/goldmarkwith GFM, YAML frontmatter, and wikilink extensions. Used only for extracting tags, tasks, and backlinks into the FTS index — rendering still happens in the browser via the existing unified pipeline, so the Go parser is a one-way metadata extractor, not a renderer. - Config: single file at
/vault/.zennotes/server.jsonplus env vars (ZENNOTES_VAULT_PATH,ZENNOTES_BIND,ZENNOTES_AUTH_TOKEN). - Static assets: PWA bundle embedded via
go:embedat build time, so the server binary is literally the whole app — no externalpublic/directory, no separate CDN step. - No CGO:
modernc.org/sqliteis pure Go,fsnotifyuses syscalls,coder/websocketis pure Go. The binary is statically linked (CGO_ENABLED=0), which is what makes theFROM scratchimage work.
The current src/main/vault.ts is the behavioral reference. The Go
server implements the same operations — safe path resolution, soft
delete to trash/, move semantics across folders, duplicate, rename,
archive/unarchive, asset indexing, tag/task/backlink extraction — in
idiomatic Go under server/internal/vault/.
We accept a controlled duplication:
- Shared contract: the HTTP surface (§5.3) is the single source of
truth for what a "vault operation" means. An OpenAPI spec
(
api/openapi.yaml) is the machine-readable contract. - Shared test fixtures: the existing
src/main/vault.test.tsscenarios are mirrored as Go tests using the same input vaults and expected outputs. This is the anti-drift mechanism. - Code-gen clients: the client's
HttpBridge(TypeScript) is generated from the OpenAPI spec so request/response types cannot drift from the Go handlers.
Proposed server layout:
server/ ← Go module, separate from TS source
cmd/
zennotes-server/
main.go ← entrypoint, flag parsing, embed directive
internal/
vault/ ← filesystem ops (CRUD, move, trash…)
ops.go
ops_test.go ← mirrors vault.test.ts fixtures
safepath.go ← path-traversal guard
watcher/ ← fsnotify → event channel
watcher.go
index/ ← SQLite FTS5 + in-memory fallback
sqlite.go
memory.go
parse.go ← goldmark extraction (tags/tasks/backlinks)
http/
router.go ← chi routes
notes.go
search.go
assets.go
watch.go ← WS handler
auth.go
middleware.go
config/
config.go
web/ ← built PWA, embedded
embed.go ← //go:embed dist
dist/ ← output of `npm run build:web`
api/
openapi.yaml ← single source of truth for API shape
Dockerfile
go.mod
Long-term direction (not v1): once the Go server is proven, the
Electron app moves to bundling the Go binary as a child process and
the renderer talks to http://127.0.0.1:<port> via the same
HttpBridge. That collapses the two vault implementations into one
and eliminates the drift concern entirely. We note it as a direction
here and return to it in §12.
REST, JSON bodies, all under /api. Paths relative to the vault root.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/tree |
Full folder/note tree (cached, etag'd) |
| GET | /api/notes/* |
Read a note (Markdown body + frontmatter) |
| PUT | /api/notes/* |
Write a note. Optional If-Match header |
| POST | /api/notes |
Create / rename / move / duplicate |
| DELETE | /api/notes/* |
Trash a note (soft) |
| POST | /api/notes/*/restore |
Restore from trash |
| POST | /api/notes/*/archive |
Move to archive |
| POST | /api/notes/*/unarchive |
Move out of archive |
| GET | /api/search?q=&scope= |
Full-text search (FTS5 or ripgrep fallback) |
| GET | /api/tags |
All tags with counts |
| GET | /api/tags/:tag |
Notes matching a tag |
| GET | /api/tasks |
Aggregated task list |
| GET | /api/backlinks/* |
Backlinks for a note |
| GET | /api/assets/* |
Binary asset (range requests) |
| POST | /api/assets |
Upload asset |
| WS | /api/watch |
Stream of {type, path, mtime} events |
| GET | /api/config |
Client-visible config |
| POST | /api/config |
Update client-visible config |
| GET | /api/healthz |
Liveness |
| GET | /api/version |
Server version + build info |
All write endpoints accept an If-Match header carrying the last known
SHA of the note body. On mismatch the server returns 409 with both
versions and the client shows a merge UI. This gives us optimistic
concurrency without CRDTs. For the single-user case, conflicts are
rare (only across devices or with external edits), so the simple
"keep mine / keep theirs / open diff" UI is enough.
REST, JSON bodies, all under /api. Paths relative to the vault root.
| Method | Path | Purpose |
| ------ | ------------------------ | ----------------------------------------- | -------- | ---------------- |
| GET | /api/tree | Full folder/note tree (cached, etag'd) |
| GET | /api/notes/* | Read a note (Markdown body + frontmatter) |
| PUT | /api/notes/* | Write a note. Optional If-Match header |
| POST | /api/notes | Create / rename / move / duplicate |
| DELETE | /api/notes/* | Trash a note (soft) |
| POST | /api/notes/*/restore | Restore from trash |
| POST | /api/notes/*/archive | Move to archive |
| POST | /api/notes/*/unarchive | Move out of archive |
| GET | /api/search?q=&scope= | Full-text search (FTS or ripgrep/fzf) |
| GET | /api/tags | All tags with counts |
| GET | /api/tags/:tag | Notes matching a tag |
| GET | /api/tasks | Aggregated task list |
| GET | /api/backlinks/* | Backlinks for a note |
| GET | /api/assets/* | Binary asset (range requests) |
| POST | /api/assets | Upload asset |
| WS | /api/watch | {type: 'add' | 'change' | 'unlink', path} |
| GET | /api/config | Client-visible config |
| POST | /api/config | Update client-visible config |
| GET | /api/healthz | Liveness |
All write endpoints accept an If-Match header carrying the last known
SHA of the note body. On mismatch the server returns 409 with both
versions and the client shows a merge UI. This gives us optimistic
concurrency without CRDTs. For the single-user case, conflicts are
rare (only across devices or with external edits), so the simple
"keep mine / keep theirs / open diff" UI is enough.
/api/watch is a WebSocket. Server emits:
{ "type": "change", "path": "inbox/my-note.md", "mtime": 1700000000000 }
{ "type": "add", "path": "inbox/new.md", "mtime": 1700000001000 }
{ "type": "unlink", "path": "inbox/old.md" }Clients debounce and reconcile against their local cache, same way the
renderer already reacts to chokidar events through IPC today. The
transport changes and the Go fsnotify driver replaces chokidar; the
client-side handling doesn't.
- Mode A (single user): On first boot the server generates a long
random token (
crypto/rand) and prints it to logs (and to a file at/vault/.zennotes/token). The user pastes it into the login screen; the server sets a long-livedHttpOnly,Secure,SameSite=Strictcookie signed with HMAC (crypto/hmac,crypto/sha256). No passwords, no accounts. Rotate withPOST /api/auth/rotate. - Mode B (pure local): no auth; the browser is the boundary.
- Mode C (multi-user): passkeys first (
go-webauthn/webauthn), argon2id passwords as a fallback (golang.org/x/crypto/argon2). Sessions in a signed cookie, refresh token in a separate cookie. OIDC is possible later as middleware; not in v1.
CSRF is handled by SameSite=Strict cookies plus an Origin header
check. All state-changing endpoints require the cookie.
Every path in a request is cleaned with filepath.Clean, resolved
against the vault root with filepath.Join, and rejected if it escapes
(check via filepath.Rel + prefix match, not string comparison on
Windows). Symlinks outside the vault are rejected explicitly. The Go
tests port the same path-traversal cases from vault.test.ts so
parity is checked, not assumed.
Search via ripgrep/fzf is opt-in and runs through os/exec with a
fixed, hardcoded argv prefix (no shell, no user-supplied flags).
Disabled by default for remote deployments.
Everything in the renderer that used to call window.api.* goes
through a single VaultBridge interface:
interface VaultBridge {
listTree(): Promise<VaultTree>;
readNote(path: string): Promise<NoteContent>;
writeNote(path: string, body: string, etag?: string): Promise<WriteResult>;
// …one method per current IPC handler…
watch(handler: (ev: WatchEvent) => void): Unsubscribe;
}Three implementations ship; a Vite env flag picks which is wired up at boot:
ElectronBridge— current preloadwindow.api, unchanged.HttpBridge—fetch+ WebSocket against the Go server. The request/response types are generated fromapi/openapi.yaml(viaopenapi-typescriptand a tiny fetch wrapper), so the bridge cannot silently drift from the server's handlers.FsaBridge— File System Access API, used by Mode B. Implements the sameVaultBridgesurface againstFileSystemDirectoryHandle, with an in-memory watcher backed by a polling mtime scan (the FS Access API does not expose change events).
Nothing else in the renderer changes. Zustand store, CodeMirror, preview pipeline, search palette, command palette, theme system — all untouched. That is the whole point of going through a bridge: the port is additive, not a rewrite.
- Manifest (
manifest.webmanifest):display: standalone, theme colors matching the 8 ZenNotes themes, maskable icons at 192/512,scope: /,start_url: /. - Service worker: Workbox. Precache the app shell (JS, CSS, fonts,
theme CSS, icons). Runtime:
GET /api/tree→ StaleWhileRevalidate.GET /api/notes/*→ StaleWhileRevalidate, mirrored into IndexedDB for offline reads.GET /api/assets/*→ CacheFirst with long max-age.PUT /api/notes/*→ NetworkFirst with Background Sync: if offline, queue in IndexedDB and replay on reconnect.
- Update flow: new bundle triggers a subtle banner ("Reload to apply update"), same as Electron today, but in-app.
IndexedDB has three stores:
notes— mirror of server responses keyed by path, value is{ body, frontmatter, mtime, sha }.pendingWrites— queue of writes taken while offline.tree— last tree snapshot for instant startup.
Startup path, online: paint from IndexedDB immediately, revalidate from server, merge. Startup path, offline: same, just no revalidate.
Conflict rules (single-user assumption):
- Server wins on read, client wins on write,
If-Matchcatches true conflicts. - If
If-Matchfails, the editor shows a three-way view (mine, server's, merged) and lets the user pick. We already have a diff renderer via the unified pipeline; we reuse it.
No regression. The current bindings already run in the renderer, which is now the PWA. Things to double-check on the web side:
Ctrl/Cmd+N,Ctrl/Cmd+W,Ctrl/Cmd+Tare captured by the browser. We remap the app defaults toLeader+n,Leader+w,Leader+tfirst-class, and document the browser conflicts. This is already how the vim bindings work.- The command palette (
Ctrl/Cmd+P) is claimed by some browsers' print dialog on certain OSes; we intercept viakeydownandpreventDefaultand document theLeader+palternative. - Global shortcuts do not exist on the web.
zen://deep links becomehttps://notes.home.lan/open?path=…so links in other apps still work when the PWA is installed.
- Chromium desktop: we show an install prompt the first time the user stars a note or pins a reference pane, not on first load. The idea is to ask only once there's something worth installing.
- iOS/iPadOS: standard "Add to Home Screen" flow; we render an explainer if the user visits on Safari and hasn't installed.
- We do not ship
windowControlsOverlayin v1 — it's nice but not necessary.
Unchanged from the desktop build. The server cares about:
/vault/
inbox/
quick/
archive/
trash/
attachements/
.zennotes/
server.json ← config: auth token, ports, feature flags
index.db ← SQLite FTS (optional)
state.json ← server-side UI hints (pin order, etc.)
token ← generated bearer token
No database is required. A fresh vault boots with no .zennotes/
directory and the server creates what it needs.
- Tables:
notes(path, title, body, mtime, sha),notes_fts(title, body, content=notes),tags(tag, path),tasks(path, line, priority, due, waiting, done, body),backlinks(from_path, to_path). - Built on first run, maintained incrementally by watcher events.
- If the index file is deleted, the server rebuilds it on next start. This is the user's "repair" button.
For vaults under ~2k notes, a pure in-memory index matches or beats SQLite and avoids the file. We keep both and pick by vault size.
Served via GET /api/assets/* with Cache-Control: public, max-age=31536000, immutable
keyed on a content hash (not the filename). Range requests supported
so videos/audio stream without loading whole files. The renderer still
references them with a zen-asset:// scheme, which the bridge rewrites
to /api/assets/... at paint time. That keeps the markdown file
portable between desktop and web.
The existing MCP server in src/mcp/ is Node-based and uses the
upstream @modelcontextprotocol/sdk. We deliberately do not port
it to Go in v1:
- The upstream SDK is well-maintained in TypeScript; the Go MCP ecosystem is still young.
- The MCP server doesn't sit on the request hot path — it's invoked by external clients (Claude Desktop, Claude Code, Codex) against the same vault. A Node sidecar has no user-visible performance cost.
- Keeping it in TS lets us share the existing vault-operation helpers
in
src/mcp/without rewriting them against the Go server's HTTP surface.
Deployment options:
- Sidecar container (recommended for home servers). A second
container —
ghcr.io/zennotes/mcp— shares the vault volume and is reachable over stdio from clients running on the same machine (viadocker exec) or over a remote MCP-capable transport like SSE-over-HTTPS when that spec stabilizes. - MCP-over-HTTP bridge. The Go server can optionally expose
/api/mcpthat proxies to the Node sidecar, so remote clients don't need direct container access. Opt-in, off by default.
For Mode B (pure local, no server), MCP is not available. This is a known limitation: MCP needs a process, and a pure browser deployment doesn't have one. Users who want MCP pick Mode A.
The settings UI that today generates config snippets for Claude Desktop / Claude Code / Codex stays, but it now offers two variants per client: "local stdio" (desktop build) and "remote tunnel" (home server, pasted into the client's remote MCP config).
Future: once the Go server is proven, we can evaluate porting the MCP server to Go using one of the emerging community SDKs. Not a v1 concern.
- TLS is required. The PWA features we depend on (service worker,
install prompt, notifications, File System Access, secure cookies)
only work over HTTPS or
localhost. We document three TLS paths: Caddy auto-HTTPS with Let's Encrypt, Tailscale Funnel, and Cloudflare Tunnel. Each is one config block. - Cookies:
HttpOnly,Secure,SameSite=Strict, name-prefixed with__Host-. - CSP: strict default,
script-src 'self', no inline scripts. The markdown pipeline already produces safe HTML through DOMPurify. - Rate-limit auth endpoints via
chimiddleware (httprate.LimitByIP) — 10 requests/minute on login, 100/minute on the rest. - Path traversal: all paths cleaned + resolved against vault root; no
..escape, no absolute paths, no symlink traversal outside root. - No arbitrary shell-out. ripgrep/fzf integration is opt-in, uses
os/execwith a fixed argv prefix, and never interpolates user input into a shell string. - No telemetry from the server. No telemetry from the PWA by default. If we add optional usage metrics later, they are off by default and self-hosted (e.g., Plausible).
- Primary artifact: a single static binary,
zennotes-server, roughly 15–25 MB uncompressed, with the PWA bundle embedded viago:embed. Users can download it from GitHub Releases,chmod +x, and run it. No runtime dependencies. - Cross-compiled targets: linux/amd64, linux/arm64 (for NAS and
Raspberry Pi), linux/armv7 (older Pis), darwin/amd64, darwin/arm64,
windows/amd64. Produced in one
make releasepass withGOOS/GOARCHmatrix. - Docker image:
ghcr.io/zennotes/server:x.y.z, builtFROM scratch(orgcr.io/distroless/staticif we want a CA bundle baked in), ~15 MB. Multi-arch manifest for amd64 and arm64. Because the binary is static and the image is scratch, there is no shell, no package manager, and no userland CVE surface to patch. - Reproducible builds:
-trimpath -ldflags="-s -w -buildid=",SOURCE_DATE_EPOCH, and goreleaser for release automation. - Compose example:
services:
zennotes:
image: ghcr.io/zennotes/server:latest
restart: unless-stopped
volumes:
- /srv/vault:/vault
environment:
ZENNOTES_VAULT_PATH: /vault
ZENNOTES_BIND: 0.0.0.0:7878
ports:
- "7878:7878"
zennotes-mcp:
image: ghcr.io/zennotes/mcp:latest
restart: unless-stopped
volumes:
- /srv/vault:/vault
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
volumes:
caddy_data:with a one-line Caddyfile that terminates TLS and proxies to the app:
notes.home.lan {
reverse_proxy zennotes:7878
}
The client bundle lives inside the Go binary via go:embed. On a
new release, the user replaces the binary (or pulls the container),
restarts, and the PWA auto-updates in browsers via the service worker.
Versioning matches the desktop app's; GET /api/version returns both
the server version and the embedded client's commit hash so mismatches
are impossible.
One page in the README plus one docs/self-hosting.md that covers:
- Binary quickstart: download,
chmod +x, run, open browser. - Docker Compose quickstart: copy Compose file,
docker compose up -d. - systemd unit: for users who want to run the binary directly
without Docker (example
zennotes-server.serviceincluded). - TLS (Caddy, Tailscale, Cloudflare Tunnel — pick one).
- Backups (vault is just files; rsync works).
- Upgrades:
docker compose pull && up -d, or replace the binary. - Pairing with the desktop app for hybrid use.
Each phase is shippable on its own. We cut releases at each boundary.
- API contract. Draft
api/openapi.yamlfrom the current IPC surface. Wire a TypeScript client-type generator into the renderer build. No backend code yet — this locks down the shape before either side is implemented. ~0.5 week. - Go server skeleton.
cmd/zennotes-server, chi router, config loader, health endpoint, staticgo:embedof a placeholder page, Dockerfile. Boots in milliseconds, does nothing useful yet. ~0.5 week. - Port vault operations to Go.
internal/vaultpackage with full parity againstsrc/main/vault.ts. Shared test fixtures drive both sides. This is the biggest single phase. ~2–3 weeks. - Watcher + index.
fsnotifywatcher, SQLite FTS5 index with goldmark-based extraction for tags/tasks/backlinks, in-memory fallback for small vaults. ~1.5 weeks. - HTTP + WebSocket handlers. Wire vault + watcher + index behind
the OpenAPI routes. PWA runs against the Go server with a dev
HttpBridge. Editing, search, watcher work end-to-end. ~1 week. - PWA shell. Manifest, service worker, IndexedDB cache, install prompt, offline reads. ~1 week.
- Offline writes and conflict UI. Background Sync queue,
If-Matchconflict flow, merge modal. ~1 week. - Auth + Docker. Bearer-token single-user auth with HMAC-signed
cookies, Compose file, Caddy example, first public release of
zennotes-serveron GitHub Releases andghcr.io. ~1 week. - File System Access mode (Mode B).
FsaBridgeimplementation, entry-level "open a folder in your browser" flow. ~1 week. - MCP sidecar image. Package the existing Node MCP server as a second container and document remote setup. ~0.5 week.
- Multi-user (Mode C) — only if demand is there. Passkeys, per-user vault dirs, session store. Deferred, scoped separately.
Total to first home-server release: roughly eight to ten weeks of focused work. The Go port (phase 3) is the biggest single chunk; the rest is mechanical once the contract is locked.
- Collapse to one vault implementation. After v1, do we bundle the
Go binary inside the Electron app and have the renderer talk to
http://127.0.0.1:<port>in both modes? That collapses the two vault implementations into one and kills the drift-risk argument against Go entirely. Cost: Electron process model gets more complex (supervisor + child), and resource usage on desktop grows by one process. Recommendation: yes, but only after the Go server is proven in the wild for a release or two. - SQLite index vs on-demand scan. Large vaults (>10k notes) will need the index; small vaults don't. We ship both and switch by size. Open: where's the exact cutover, and do we let users force one?
- Realtime multi-device editing. Not a v1 goal, but the transport
(WebSocket) and the versioning (
If-Match) are picked so we can graduate to CRDTs (Yjs; server side would useautomerge-goor a WS passthrough) without rewriting the server. - Mobile editor ergonomics. CodeMirror 6 works on mobile, but vim
mode without a physical
Escand modifier keys is rough. If we want mobile to be good, we need a slim virtual toolbar. Out of scope for v1; tracked. - MCP over remote transport. Stdio is local-only. Remote MCP is
still a moving target across clients; we watch the spec and pick
when it settles. Sidecar +
docker execis the bridge until then. - Port MCP to Go eventually? Only if the community Go SDK catches up with the TS one. No pressure to do it in v1.
- Pure-browser MCP. There is no plausible v1 path here. Mode B users either run the desktop app or the home server when they need MCP.
- The renderer already runs in a browser; the port is mostly additive on the client side.
- Backend is Go: a single static binary (
zennotes-server) built with chi +coder/websocket+modernc.org/sqlite+fsnotify, with the PWA embedded viago:embed. Ports the vault operations fromsrc/main/vault.tsinto idiomatic Go, validated by shared test fixtures and an OpenAPI contract. - Wire the renderer through a
VaultBridgewith three implementations:ElectronBridge(unchanged),HttpBridge(generated from OpenAPI, talks to the Go server), andFsaBridge(File System Access API, pure-local mode). - Ship a single binary + a
FROM scratchDocker image + a Caddy example as the home-server story. Ship the pure-local browser mode for users who want zero backend. Keep the Electron desktop app. - MCP rides along as a Node sidecar container against the same vault; re-evaluate porting to Go after v1.
- Keyboard-first and vim-first are preserved, because the whole interaction layer is untouched — we only swap the transport and re-implement the vault logic in a language better suited to running on someone's home server 24/7.