Skip to content

Architecture

One node, one owner

Before any of the parts: a Novaterra install is one person’s operating system. The database carries a partial unique index, beings_single_owner, that permits exactly one owner per install. That is not a limitation waiting to be lifted — it is the property that makes everything else on this page honest. One node, one owner, one person’s data on hardware that person holds.

The intention is that worlds mesh — that your node and someone else’s exchange signed messages, posts and listings, rather than sharing rows in one database. That is now real. Every being has a portable Ed25519 keypair, addressed as did:key:z…, and one node reaches another directly: POST /api/federation/peers adds a peer by hand, a two-round-trip handshake creates no session, and messages travel sealed (X25519 + HKDF-SHA256 + AES-256-GCM) through a store-and-forward queue that retries with backoff up to a 1-hour ceiling for about two days before it gives up and calls a peer “unreachable” — deliberately not an error. See Identity and the mesh for the protocol and Moderation for what keeps a meshed world governable without a central authority.

(This paragraph used to say: “None of that exists yet. There is no node-to-node protocol in this repository, and a being’s identity is a local database id, not a portable key.” That was true when written and is superseded now that identity and federation have shipped.)

The monorepo

novaterra/
apps/
web/ React 19 + TS + Vite 7 + Tailwind v4 + motion + Zustand + TanStack Query + React Router 7
api/ Node 22 + TS + Fastify 5 + ws (WebSocket) + Drizzle ORM + better-sqlite3 + FTS5
docs/ This documentation site (Astro + Starlight)
packages/
contracts/ Zod schemas + TS types for every entity, API route, and WS event — built first, imported everywhere
llm/ The OpenAI-compatible client, per-tier model router, streaming, structured output, tool loop, embeddings
agents/ Planner → crew assembly → task runner → deliverable contracts → QA — see Agents & Skills
skills/ The skill registry, every built-in skill, and the plugin host
souls/ Personality engine: soul schema, empathy layer, the system-prompt compiler, the citizen templates
ui/ Design system: tokens, glass primitives, the shader background engine, motion presets
plugin-sdk/ Author-facing SDK for third-party plugins — see Plugins & extensibility
sandbox/ Worker/subprocess/container isolation and a capability broker for plugin code
seed-sdk/ The minimum protocol client — no zod, no node:crypto, small enough for an ESP32;
proof the wire isn't accidentally desktop-shaped
services/
directory/ Run by Vocabotics, not part of any node: a lookup service whose answers are signed
by the being or node they're about, not by the directory — see Identity & the mesh
workspace/ Runtime data: generated Studio projects, file uploads, installed plugins (gitignored)
runtime/ Rust. nova-wasm (a wasmtime host with a hand-written WASI) and nova-sandbox
(Linux namespaces, cgroups v2, seccomp, Landlock) — see Sandboxing
docker/ The now-optional sandbox image for code.execute (Node + Python)
deploy/ Production topology: Vercel + Cloudflare (chosen), plus Fly.io, a bare VPS, and Docker Compose variants
tests/ vitest. 1,752 tests: 1,725 pass, 27 skipped

apps/api/src/modules/ holds twenty-three slices, each one a Fastify plugin — five more than it used to: identity/ and federation/ (a being’s portable keypair and the signed, sealed wire to another node — see Identity and the mesh), moderation/ (reporting, the owner’s queue, blocking, a node’s Square admission policy — see Moderation), directory/ (this node’s client for the lookup service at services/directory), and meetings/. Beyond the ones the pillars describe, the ones worth knowing about before you go looking for them elsewhere: payments/ (Stripe top-ups — see Payments and credits), admin/ (the owner-only operator console — see The admin console), plugins/ (install and grant — see Plugins and extensibility), and spaces/ and groups/, which give the world its words for where and who (see The primitive ontology).

Groups: crews and companies are the same thing

A Group is a named set of beings with roles, addressable as one. Its kind is an open string whose known values are crew, company, household, circle and team — a crew and a company differ by that word and nothing else. Groups nest: Group.parentId puts a crew inside a company, or a team inside a department, up to a hard depth of eight.

The important part is what nesting deliberately does not do:

Containment is descriptive. It grants nothing.

Every place group membership gates access — entering a space, listing your spaces, listing your groups, your role, and every “are you of this group” check on the group routes — still reads the membership table for one exact group id. Being of a crew does not make you a member of the company above it, and being of a company does not admit you to a crew’s workroom, seat you in it, or let you read its roster. A crew member gets a 403 on the parent, and a company member gets a 403 on the crew. A hierarchy that quietly implied inherited access would be a security change wearing a modelling change’s clothes.

There is one read that spans the tree, and it is named so it cannot be mistaken for the roster: GET /api/groups/:id/tree-members answers “everyone in this company”. Its gate is stricter than the roster’s — lead or owner only, because it reaches into groups the caller may not be of — and every row it returns names the group that being is really of. Nothing in the world’s authorisation path consults it.

What members do learn from the tree is the name and kind of the groups above and below theirs, never their rosters. Creating or moving a group inside another needs lead-or-owner of both ends, because containment is a claim about the parent too.

A crew staffs a project

PUT /api/projects/:projectId/crew binds a group to a Studio project. The next run draws its people from that group’s seats instead of assembling strangers by skill; clearing the binding restores automatic assembly. Choosing a crew starts nothing — the run reads the row when it happens.

Binding a company is allowed, because refusing anything but a leaf would be a rule about kind in a system whose premise is that a company and a crew are the same row. But the pool does not widen on its own: includeDescendants defaults to false, is opt-in per project, and needs lead-or-owner of the bound group to set — a company’s tree is a spend surface as much as a roster.

pnpm-workspace.yaml defines apps/* and packages/* as one dependency graph; packages export TypeScript source directly (no build step between them), so a change in packages/contracts is visible to apps/api and apps/web the moment you save it. packages/contracts is built first and never depends on anything else in the monorepo — everything else depends on it.

Data: SQLite, on purpose

One file, apps/api/data/novaterra.db, via Drizzle ORM over better-sqlite3 — zero infra, instant boot, and exactly the right amount of database for one person’s node. Twenty-nine tables are defined in Drizzle; roughly twenty-five more are created as idempotent raw SQL the first time the feature that needs them is used, so an additive feature never requires a schema migration against a live database that already holds somebody’s memories. Two things make it punch above its weight:

  • FTS5 virtual tables mirror memories (and file summaries) for fast full-text search, kept in sync by triggers rather than application code — see The memory model for how this fuses with embeddings for hybrid recall.
  • Embeddings as blobs — memory and file embeddings are stored as raw Float32Array buffers in the same database, with cosine similarity computed in plain JavaScript. Datasets are small enough (a personal Twin’s memories, not a web-scale corpus) that this beats standing up a vector database.

Resetting a world is as blunt as the storage model: stop both servers, delete apps/api/data/, restart — the seed re-runs cleanly, idempotently, every time.

Realtime: one WebSocket, typed events

Every client holds exactly one WebSocket connection (/api/ws, packages/contracts/src/events.ts is the full catalogue). The server hub (apps/api/src/core/ws.ts) tracks one Conn per socket, grouped by being, with a 30-second heartbeat that terminates any socket that stops answering — closing a laptop lid never leaves a ghost “online” presence behind. Application code never touches a raw socket: every module calls publish(beingId, event) or broadcast(event), and the hub fans it out to every connected tab for that being (or everyone, for broadcast).

ServerEvent =
| { type: 'hello', beingId, serverTime }
| { type: 'signal.created' | 'signal.updated', signal }
| { type: 'message.delta', threadId, messageId, delta }
| { type: 'message.created', message }
| { type: 'message.tool', threadId, messageId, toolCall }
| { type: 'task.updated', task }
| { type: 'project.updated', project }
| { type: 'trace', event }
| { type: 'memory.created', memory }
| { type: 'widget.suggest', widgetType, reason, config? }
| { type: 'presence.update', beingId, status, doing? }
| { type: 'wallet.updated', wallet }
| { type: 'twin.activity', kind, summary, at }
| { type: 'square.post', post }
| { type: 'ambient', mood, hint? }

This one catalogue is what makes the desktop feel alive without polling: a Signal appearing, a Muse reply streaming token-by-token, a Studio task’s status flipping, a widget suggestion, a citizen posting in the square — every one of these is the same mechanism, a typed frame over the same socket.

The LLM router

Every call in the system names a tier (cheap / standard / strong / image / embed), never a raw model id — the whole mapping lives in one file, packages/llm/src/models.ts. See Models and the budget guards for tier meanings and the spend-guard mechanism (apps/api/src/core/budget.ts), which checks every call against three ceilings before it goes out, not after.

The client (packages/llm/src/client.ts) speaks the OpenAI-compatible protocol against a configurable baseUrl — and each tier resolves to its own provider entry (packages/llm/src/providers.ts), so standard can point at an Ollama box on your desk while strong stays on OpenRouter. A local call is marked costsMoney: false and therefore does not draw down the USD budget. GET /api/llm/inference is what Settings → Inference renders: which provider each tier resolved to, base URLs only, never a key. See local-first inference.

A local tier never silently falls back to a remote one. LOCAL_LLM_FALLBACK is opt-in, because a sleeping GPU must not quietly ship a prompt you meant to keep at home.

Places

The SPA lazy-loads every Place from apps/web/src/App.tsx:

RouteWhat it is
/worldthe Living Desktop
/messagesevery conversation in one inbox — see The messaging centre
/musethe Muse
/twinmemory, profile, files — see Your Twin
/studio, /studio/:projectIdthe Studio
/workthe portfolio and the cross-project step queue
/marketthe Marketplace
/spacesspaces, groups and crews
/beings, /beings/:idwho lives here, and one being’s passport
/settingsconnections, inference, voice, plugins, access codes
/adminthe owner-only operator console

There are two more: /moderation, the owner’s queue for reports and posts arriving from other nodes (see Moderation and the Square), and /m/<name>, which is a Place an installed module contributes.

Eight built-ins sit in the rail, and then whatever modules are installed — one hook merges the two lists, which is what makes a module appear there without a redeploy. /spaces, /settings, /moderation and /admin are reached by other doors.

A module’s interface renders in an iframe with an opaque origin and no network of its own, and everything it does passes through one capability-checked call. That boundary is proved in a real browser rather than asserted; see Plugins and extensibility.

/work: the view above one project

The Studio describes a single project well and, until recently, said nothing about several of them. Worse, a review gate waiting on a human decision had stopped real work and was invisible unless you happened to open the project it belonged to.

  • GET /api/studio/portfolio — every project at once, grouped by what it needs from you. Its four standings are waiting, running, stalled and done, and waiting outranks done: a question still sitting on your desktop is not “finished”.
  • GET /api/studio/queue — every open step across every project, in one list. Your steps and a spark’s steps are the same array, filtered by who, never split into “People” and “Agents”.
  • PUT /api/studio/projects/:id/due — a target date on a project or on one of its steps. It is held in a side table rather than on the task row, because the runner rewrites a task’s output wholesale on every attempt and would destroy a date you had set by hand.

Every one of these starts from your projects and constrains every later query to the ids that came back. There is no code path that widens that set.

Security headers and rate limits

registerSecurity() (apps/api/src/core/security.ts) registers Fastify’s helmet integration (nosniff, frameguard: sameorigin, referrer-policy: strict-origin-when-cross-origin, cross-origin-resource-policy: same-site, no CSP on the JSON API itself — HSTS is left off locally and added by the reverse proxy in production) and a global rate limit (300 requests/minute per IP by default, RATE_LIMIT_PER_MINUTE), plus named stricter limits for sensitive routes:

LimitApplies toCap
authlogin/register10/min
waitlistthe public waitlist form5/min
ownerminting access codes20/min
llmanything that calls the model directly (e.g. the omnibar)30/min
uploadfile uploads30/min

Every connection secret (Gmail tokens, Telegram bot tokens, a files connection’s root path) is encrypted at rest with AES-256-GCM (apps/api/src/core/crypto.ts), keyed from ENCRYPTION_KEY. See Security & privacy for the full picture, including session cookies and what the Docker sandbox actually isolates.

GET /api/health

One cheap, unauthenticated endpoint an operator (or an uptime check) can poll: server uptime, whether the LLM key is present, live SQLite latency, Docker availability (probed once and cached for a minute so the endpoint itself never blocks on it), the current budget snapshot, live WebSocket connection/presence counts, process memory, and an embeddings block — coverage across every being’s memories and whether anything measurably lacks a vector (degraded: true when so), since a broken embedder degrades hybrid recall to keyword-only silently otherwise. This is the single source of truth Settings, the self-hosting checklist, and anyone SSH’d into the box all read from.

One caveat worth knowing while reading it: the docker field is still the only sandbox signal the report carries, and the desktop’s world widget renders “sandbox ready” from it. Since code.execute now defaults to the WASM tier, a machine with no Docker and a perfectly working sandbox will read as “no sandbox” there. The health field is accurate about Docker; it is the label above it that is behind.

Where this is going

The direction is federation, not multi-tenancy: many nodes, each with one owner, exchanging signed data — rather than one database with a column naming which customer each row belongs to. A first version of that has shipped: portable identity, the federation handshake and message queue, and moderation are real today — see Identity and the mesh and Moderation. What is still ahead — the full range of node profiles (a keypair on an ESP32 through to a datacentre relay, all on one protocol) and the rest of the roadmap — is in design/OS-PLAN.md in the repository.

(This paragraph used to end: “Nothing on that road is built, and the biggest single gap in the product today is that there is no inter-node protocol at all.” That gap is closed; what remains is breadth of node profiles, not existence of the protocol.)