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 skippedapps/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
Float32Arraybuffers 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:
| Route | What it is |
|---|---|
/world | the Living Desktop |
/messages | every conversation in one inbox — see The messaging centre |
/muse | the Muse |
/twin | memory, profile, files — see Your Twin |
/studio, /studio/:projectId | the Studio |
/work | the portfolio and the cross-project step queue |
/market | the Marketplace |
/spaces | spaces, groups and crews |
/beings, /beings/:id | who lives here, and one being’s passport |
/settings | connections, inference, voice, plugins, access codes |
/admin | the 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 arewaiting,running,stalledanddone, andwaitingoutranksdone: 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:
| Limit | Applies to | Cap |
|---|---|---|
auth | login/register | 10/min |
waitlist | the public waitlist form | 5/min |
owner | minting access codes | 20/min |
llm | anything that calls the model directly (e.g. the omnibar) | 30/min |
upload | file uploads | 30/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.)