Skip to content

Architecture overview

Subspace is built on one deliberate bet: write everything in one language, and keep all durable state in one database. Server, web UI, desktop shell, mobile app, Chrome extension, agent runtime, and sandboxed user functions are all TypeScript. Every byte that must survive a restart, pages, mail, calendar, tasks, agent runs, embeddings, lives in one embedded PostgreSQL instance. The result is a system one person can hold in their head and grep end to end.

The incumbent design this replaces paid for a Clojure server talking to a Python agent sidecar over RPC, a Rust shell over another RPC, and hiccup templates hydrating into JS islands. Every one of those seams is a place where a schema can drift and a device can break at runtime instead of at compile time.

Subspace collapses those seams into imported functions and shared types. There is one pnpm monorepo, one type system, and one pure reducer (@subspace/kb-core) that runs identically on the client (optimistically) and the server (authoritatively). Two surfaces, the browser and the extension, must be JavaScript no matter what; the bet is that unifying everything else is worth more than any per-component “best tool for the job.”

The stack is deliberately boring and pinned, with a written no-churn policy: Node LTS, Postgres, React, Electron, Expo. Novelty is spent only where the problem genuinely demands it (see where the philosophy is stretched).

One Node process, subspace-server, owns all durable state. It holds that state in one supervised embedded Postgres instance reached over a unix socket, never a network listener. Every surface is a client of that one server over two channels: tRPC (typed request/response, zero codegen) and a WebSocket topic bus (live updates).

┌──────────────────────────────────────────────┐
browser SPA ───┤ subspace-server (single Node process) │
(React) │ http: Hono · rpc: tRPC · live: ws topic bus │
desktop shell ─┤ kernel: kb reducer + index maintenance │
(Electron) │ dispatcher: LISTEN/NOTIFY → outbox → bus │
mobile (Expo) ─┤ engines: scheduler · agents · triage · sync │
chrome ext ────┤ pg-embed: supervises Postgres (unix socket) │
└───────────────────────┬──────────────────────┘
PostgreSQL + pgvector
(bundled binaries, ALL durable state)

Most surfaces only ever pull work from the server. Two satellites also receive work over their persistent WebSocket:

Mobile reaches the server over Tailscale; push goes through Expo’s relay. The server runs on your laptop first (supervised by the shell) and moves to an always-on box by moving one Postgres data directory plus the blob directory. See self-hosting for the move.

Every choice below flows from the two rules (one language, one database):

Layer Choice Why
Language TypeScript, strict, ESM one type system end to end
Runtime Node.js LTS boring, strong native-module ecosystem (pty, zmq)
Database PostgreSQL + pgvector, embedded one transactional store; MVCC means a Gmail backfill never queues behind a keystroke
Driver postgres.js (pure JS, pooled, pipelined) removes the last native module from the storage path, no ABI rebuild churn
Query layer Drizzle ORM + drizzle-kit migrations typed SQL, plain transactional SQL migration files
Validation Zod one schema is runtime validation plus an inferred static type, shared by every surface
HTTP / RPC Hono + tRPC + TanStack Query end-to-end types with no codegen
Live ws, topic fan-out fed by the outbox dispatcher one socket per window, sub/unsub per topic
Web UI React + Vite SPA (served by the server) a local single-user app has no SEO or cold-load problem, so no SSR
Desktop Electron (main process is Node/TS) node-pty, hotkeys, supervision all in-language
Mobile Expo (React Native) capture-first native UX plus a WebView for full pages
Agent runtime Workflow DevKit over a self-hosted Postgres World durable waits, sub-agents, arbitrary topologies, steps stay Postgres rows
Search Postgres FTS (tsvector + GIN) plus pgvector HNSW lexical index updates in the same transaction as the write; real ANN in the same database

The workspace is a handful of packages consumed as typed imports, with no build orchestrator beyond tsc -b and Vite:

  • packages/schema, Zod schemas and shared types (the command envelope lives here)
  • packages/kb-core, the pure reducer and tokenizer/parser
  • packages/formula, the table-formula parser and evaluator (see tables and formulas)
  • packages/okf, the OKF codec
  • packages/api, tRPC router types
  • packages/ui, shared React components
  • apps/server, apps/web, apps/desktop, apps/mobile, apps/extension

Only two native modules remain in the whole tree: node-pty (server and shell PTYs) and zeromq (server-side Jupyter kernel client). The storage path is pure JavaScript.

The one-language rule is genuinely weak in exactly three places, and each has an explicit, designed answer rather than a hand-wave:

System-audio capture (a Swift helper)

Electron cannot capture macOS system audio, so meetings use a small Swift ScreenCaptureKit helper that pipes PCM over stdout to the shell. This is a deliberate purity break, kept to about 200 lines and confined to one channel of one feature. Microphone capture stays in a hidden getUserMedia window.

Jupyter kernels (foreign processes)

Code cells run real Jupyter kernels, which can be any language a user installs. Subspace never runs Python of its own: the server implements the language-neutral Jupyter wire protocol directly over zeromq and spawns kernels from their kernelspecs. The foreign code is the user’s, not ours.

Embedded Postgres under Node

Node has no well-trodden “Postgres in a pip package” equivalent, so this is the design’s novelty budget, spent on purpose. Subspace builds its own per-platform Postgres and pgvector binaries and supervises them from a small pg-embed module. See Storage and Postgres for the full rationale and the major-version upgrade path.

Bundled binaries (Postgres, pgvector, WAL-G) count as dependencies, not seams: none of them carries code of ours in another language.