Lucia OS documentation

A personal computer you actually own.

A sealed document world that remembers the way you do, that you visit on your own rhythm, and that no landlord can read, monetize, or revoke. This is how it is built.

Getting started

Build it, log in, work the tree

The terminal is the trusted seat: it holds your keys, does every plaintext operation, and renders your document world. Everything below runs on your own machine.

Build and launch

Compile the window binary, then open it on a store. The store argument is optional; without it the binary opens on your default local store. run.sh is the dev shortcut: it builds incrementally and launches on ~/.lucia-terminal/realm in one step.

./build.sh terminal                 // build build/lucia-terminal (CMake, Release)
./build/lucia-terminal [store]      // open the window on a store
software/terminal/run.sh          // build + launch on ~/.lucia-terminal/realm

First run: the lobby

With no identity yet, the terminal opens in the lobby. The lobby holds only the verbs that produce a seat. register births your account and logs you in; login reopens it later. On a standalone machine the realm is local, so register dy makes the account dy@local.

  • register <user> <passphrase> mints a random identity, wraps it under your passphrase, and logs in.
  • login <user> <passphrase> unwraps that identity again. A wrong passphrase decrypts nothing, so the failure itself is the check; nothing verifier-shaped sits on disk.
  • recover <user> resets a forgotten passphrase from your one-shot recovery code, and passwd changes it while the identity stays fixed.

Your keys derive from your passphrase; no private key is stored. Every document you keep is sealed under those passphrase-derived keys before it touches disk, so content is encrypted at rest and readable only in your logged-in seat. Your first login installs the /bin command set into your home as sealed app documents.

A short session

register dy hunter2            // a new account, dy@local, wrapped by your passphrase
welcome, dy@local - content is encrypted
dy@local:/> mkdir notes
dy@local:/> put notes/day1 "first light"
dy@local:/> ls notes
day1
dy@local:/> cat notes/day1
first light

The shell command set

After login you are in the shell: one session, one cursor, bare verbs. The verbs group by job.

cursor
Where you stand

cd move (~ home, - back) · pwd show it · at <version> time-travel the tree · now return to head.

files
Documents in the tree

ls (-l -a -R) · cat · put <path> <text…> · mkdir · touch · rm · mv · edit <path> [-w <text…>] · grep (-i -l) · find.

sharing
Pass access, sealed

share <path> with <user> seals a file to a reader · grant <who> <path> <right> and revoke pass or drop access.

mounts
Graft other worlds in

mount <host-dir> /volumes/<name> a live view of a real folder · mount <canonical> <at> a shared subtree · mounts lists them · unmount <at> drops one.

service
Hosted capabilities

service ls · service deploy <kind> … · service rm <name> · service call <name> <verb> [args].

history
Time and sync

log [n] · versions <path> · diff <v1> [v2] <path> · sync squares with the set remote · push / pull one direction only.

agent
The coding agent

build enters agent mode in the pane. Free text is a prompt; its tool reach is exactly the volumes the seat has mounted. exit returns to the shell.

seat
Where you type

mirror starts or stops the transcript mirror to the web seat · machines lists your machines · machine names or renames this one · clear wipes the transcript.

A verb that is not built in falls through to /bin: the matching document command runs as a wasm app, confined to your home. The everyday tree verbs (ls, cat, mkdir, grep, and the rest) are these seed documents, installed at your first login.

Running a realm

Hosting the blind relay

A realm is the always-on home for a set of documents. It is a node on the wire: it serves one door over HTTP, relays sealed sync bundles realm to realm, and serves the realm's public web face. It is the untrusted half of the split, and running one asks nothing of your users' trust.

The node holds only the realm's operating secret, never a user's keys. Everything it stores and forwards is ciphertext it cannot read. Plaintext and authoring keys live only in the terminal. This is not a promise about the operator's good behavior. It is a property of how the two binaries are split, and it is what lets you host other people's documents without being able to betray them.

Terminal

Trusted · holds keys · plaintext
  • Derives keys from a passphrase (Argon2); the realm name is the salt.
  • Seals every document to readers' keys before it leaves the box.
  • Signs every commit itself; the newcomer signs their own registration.
  • Births a realm, clones it, edits, and pushes: all plaintext work.

Realm

Blind node · ciphertext only
  • Holds only keys/realm.secret, the operating key: routing and mTLS, never authoring.
  • Routes sealed frames, lists machines, folds and hands back the journal.
  • Refuses to author: hosted write verbs are not served.
  • Its identity key is pinned by every terminal; a swap is refused.

Birth, then adopt

A realm is born in the terminal, not on the node. realm --new lays down the genesis: it names the founder, shows the owner root key once, and fixes the realm name that salts every credential-derived key. Genesis produces a bundle. The node never sees a private half. To bring a node up, fold that terminal-born bundle into an empty directory, then serve it:

// stage 2: fold a genesis bundle into an empty node dir
lucia-realm adopt ./data/home.test ./data/home.test.genesis
// mints keys/realm.secret (routing + mTLS only) - no owner secret lands here

The store lives in the directory you name. keys/realm.secret is the operating key; peers/ holds pinned peer-realm identities; the journal is the signed EventLog. The node's own store is plaintext, because it is a served realm and not a terminal's at-rest vault, and everything in it is already sealed.

Serving and federating

Two faces put a realm on the wire. serve is the terminal-facing door; federate is the inter-node endpoint on the well-known port that peer realms dial to deliver:

lucia-realm serve ./data/home.test 9310       // the terminal door (loopback by default)
lucia-realm federate ./data/home.test 9310    // the realm-to-realm endpoint; port optional
lucia-realm status ./data/home.test           // name, join mode, codes, members

The terminal then connects with the transport line serve prints. For a purely local ring, the init dev shortcut provisions a servable realm from nothing on the node (lucia-realm init <dir> <realm> <founder> --pass '…'). Prefer terminal-born adopt for anything real.

Membership

The join door has three modes. An admin sets it with an ordinary commit, and the node enforces nothing it is not asked to:

  • closed accepts no allocations. invite requires a one-time code. open lets anyone self-register.
  • The admin mints a code with lucia-realm invite <dir> [--uses N] [--ttl S], authoring with the founder's own key unwrapped locally from keys/<founder>.enc. The plaintext code is shown once; the realm stores only its digest.
  • Registering a user writes only their public keys (registerUser lays down /sys/users/<name>, a home, and its grants). The node sees no private half.
  • A newcomer signs their own registration and, in invite mode, the code's consumption. The node never vouches: the acceptance rule alone admits the exact canonical write-set, checked on every terminal's fold.

The public relay

lucia-realm relay <port> <webroot> runs the blind session relay that backs terminal.luciaos.com. It shuttles opaque sealed frames between a host machine and a web seat by session id, keying nothing and reading nothing. Sessions are in-memory, drop-oldest, and evaporate when idle. deploy-relay.sh does the whole rollout in one idempotent run: it upserts the DNS A record, ships the sources, builds the relay binary and the wasm web terminal on the box, installs the systemd unit, and adds the Caddy site that terminates TLS at the edge and proxies to the relay.

What the node can and cannot do

It can route sealed frames, list a user's machines, deliver mail as postmaster signing with postmaster's own key, and fold and hand back the signed journal. It cannot read any content, author a commit onto a user, forge a signature, or substitute its own pinned identity. A hostile realm is safe not because it behaves, but because the terminal holds every key that matters.

Connecting

Connecting & your machines

You register a username with a realm once, then log in from any terminal. The same passphrase is the same you, everywhere: login unwraps your identity from this seat, and from that moment the seat holds your keys and your grants.

Your credentials are the root of everything. Argon2 over user@realm plus your passphrase derives your identity keypair. That identity seals your documents and carries your grants, so any seat that knows the passphrase unseals the same world instantly. No per-seat pairing, no provisioning step.

The passphrase is the single root of all access. Same credentials, same identity, same grants: log in and you are there.
// once, to claim the name
register dy correct-horse       a random identity, wrapped by your passphrase
// from any seat, ever after
login dy correct-horse          the passphrase unwraps your identity here

Your machine enrolls itself

On login to a federated realm the terminal registers itself in your machine registry. It keys off a stable per-machine id kept beside your keys in dir/machine.id, minted once and never moved, and takes the host name as its default label. A later login only refreshes its reachability and last-seen; your chosen name sticks. A standalone local realm skips this entirely: one machine, nothing to list.

Managing machines

Three verbs, all reading and writing the one registry:

machines                    the machines this identity owns
  studio  (this machine)     the * marks where you are
  phone
machine rename studio      relabel this machine's sticky name
machine forget phone       drop one from the registry, by its name

The registry is a sealed, journaled document under your home, /home/<user>/machines. It needs no new key and no per-device key: you already hold Write on your home, and the storage journal already gives a signed, hash-linked, append-only chain. The realm hosts the ciphertext blindly, lists it, and syncs it, but reads none of it. Machines are registry entries: a name plus a reachability note, never key-holders.

Mounting your files

To reach the real files on a machine, mount a host folder as a live volume:

mount ~/work /volumes/home   a live view of real files

A /volumes mount is a live view, never journaled or sealed. Reads and writes under /volumes touch the actual files on disk. It stays confined to /volumes and to the trusted terminal.

Revocation is coarse by design. machine forget stops a machine being listed and brokered, but a machine that cached the passphrase still holds the root. Truly evicting a compromised one means rotating the passphrase, which re-seals under a fresh identity. That is the honest cost of a single root, and it is the same cost as any full compromise.

Remote into a machine

Sign in from any browser, drive your own machine

Open terminal.luciaos.com on any device, sign in, and reach one of your machines by name. Everything you see lives on that machine. The seat is eyes and hands; the relay in the middle carries only ciphertext it cannot read.

Your credentials are the whole story. Argon2(user@realm + passphrase) derives one identity, and that identity owns named machines: the laptop terminal, a phone, the web seat. A seat that knows the passphrase resolves the same identity and the same grants. There is no pairing step and no per-device key to provision. Log in, and you are there.

On the machine you want to reach

Log in on its own terminal, a federated realm, and turn on the mirror. That one verb makes the machine reachable and announces it in your lobby.

// on the machine's native terminal
login dy@home
mirror                publish this machine, beacon it in the lobby

The mirror publishes the terminal's transcript as sealed frames and hands back the input a viewer types. The pane treats remote input exactly like local keystrokes, so the shell, build mode, approvals, and /commands all mirror without knowing the mirror exists.

On terminal.luciaos.com

The web page is the real terminal pane compiled to the browser, booted as a lobby. You sign in with the same account, and the passphrase never leaves the browser: Argon2 runs client-side, and only the derived mailbox and a sealed key ever touch the relay.

// in the browser, same credentials you registered with
login dy@home       prompts for the passphrase; joins your lobby
machines             the machines that are online right now
remote laptop        join that machine's room, see its shell

login <user>@<realm> joins your identity's lobby, one room per (user, realm) where every machine beacons its presence. machines reads the live roster. remote <name> joins that machine's room and shows its shell. Running remote <other> is remoting into another: same seat, a different room.

The seat is where you type. The machine is where the keys and the plaintext live. A remote seat is eyes and hands only.

How it stays sealed

  • The mirror mints a fresh per-session key and seals the transcript under it. A wrap key derived from your credentials seals only the one handshake frame that hands the session key across, so a passphrase cracked later cannot read a past session.
  • Each machine is its own switchboard room. The room address is f(user, realm, machine), folded from the identity's private seed, so it is unguessable to anyone holding only the public card. The empty machine is the lobby.
  • The relay is a blind N-peer switchboard. It routes sealed frames by an opaque peer id and forwards each to every other peer. It holds no reader key and no authoring key, and reads nothing. A passphrase holder is the only reader.

This is the same experience whether the seat is the web terminal or a native terminal acting as a viewer. Both derive the identical keys from the same credentials and ride the same sealed frames, so there is no code to copy and nothing to trust in the middle.

Architecture

A developer's map of the whole system

Lucia OS is a document-native computing environment. One format, Prism, is the storage journal, the wire protocol, and the app manifest. One trust line splits a trusted seat from a blind relay. Everything below stands on one foundation, and every document in the system has the same shape.

A Document is a set of Elements indexed by Path, each carrying typed Propertys; a Property is a Value, optionally varying over time.

That sentence is the whole model. An element's Path is its identity inside a document; a property is a typed slot; a value can be a scalar, an array, or a curve that varies over time, so a document remembers its own history natively. Everything the OS runs, stores, and ships is built from this one shape.

The layer stack

The system is a set of separate packages stacked in dependency order. Every directory under libraries/ is its own repository, checked out as a submodule. Products link only the OS facade Lucia::Os; the sibling libraries are linked by the OS and by each other, never as first-class product dependencies. The one exception is software/cli, which links core alone.

terminal · realm · lucia

Products. The trusted seat, the blind relay, and the lucia CLI. They link only Lucia::Os (the CLI links core).

lucia-os

The facade. Session, Service, Point, Guest, WasmHost: the one door products reach the realm through, and the one namespace interface every command runs on.

lucia-ai

The agent library: OpenRouter client (streaming, tool calls), agent loop, the permission gate, harness / web / session tools, /commands, sessions in .prisma, compaction, MCP core.

lucia-auth

Principals, tokens, grants, contact cards, sealed delivery. Consumes crypto; core does not.

lucia-storage

The signed journal and the one acceptance rule. Its value-add is signing and acceptance, not a second document model.

lucia-network

HTTP / TLS transport, WebSocket, the SDUI channel, the federation wire.

lucia-ui → lucia-platform

The document-native window and SDUI face, over a portable windowing and GPU RHI abstraction (macOS Metal first, Vulkan / GL cross-platform).

lucia-crypto

Monocypher-based sealing and signing. The lowest layer, with no document model. Consumed by auth, never by core.

lucia-core

The foundation. The Prism document model: diff / overlay, three-way merge, conflicts(), the EventLog, compact(). Crypto-free and portable.

The realm daemon compiles the whole OS stack including the server-only sources (Service.cpp, PublicSite.cpp) but no WASM host. The terminal drops the server-only parts and keeps the guest runtime. One codebase, two builds.

Structural facts that span packages

These invariants are load-bearing. Break one and the layering stops being real.

  • Products link only Lucia::Os. Product sources include lucia/os/* and lucia/net/* only; the facade re-exports what they need. Each product's test.sh greps for header leaks.
  • lucia-core is crypto-free. Core operates on documents whose property values are opaque strings, so diff, merge, and conflicts behave identically on sealed and cleartext values. Sealing lives in lucia-crypto, consumed by lucia-auth. The only place a signature meets the data is storage's acceptance rule, one layer up. Core stays pure because it runs inside the trusted terminal, where it is already safe.
  • Storage is a signed EventLog. Volume state is the fold of signed commits. History, three-way merge, conflicts(), and compact() live in core; storage adds signing and the acceptance rule. That rule admits a commit only on the author's own signature plus a write grant covering every touched locator. It has one implementation, run on write and on every load, so a hostile node cannot slip a commit past a terminal's fold.

One namespace interface

A command (ls, cd, grep, cat, put) never knows whether a path is a Prism document or a mounted host folder. Every command reaches storage through one interface. The interface resolves the path, asks one permission monitor once, then dispatches to whichever provider backs that locator. The document forest and a host mount are peers.

ls · cd · grep · cat · find · mkdir · rm · mv · put   commands, backend-agnosticone lucia-os namespace interface         list / open / read / stat / walk / writepermission monitor  ·  may(at)   one capability check, every backend
                    │
   ┌────────────────┴────────────────┐
 ForestProvider            HostFsProvider       backends, one provider contract
 (the document forest)     (a mounted volume)

Authorization is a capability / grant model, not POSIX path-ACLs. The monitor asks one question for a Prism document and a host mount alike: does this principal's grant set cover this locator? A mount is reached because you hold the grant that placed it, not because host paths skip the check. Adding a new backend (a remote realm, an archive, a synthetic device) is implementing one provider, with no edit to any command, to the monitor, or to the resolver.

Three layers of authorization sit under this, each with one job:

  • Journal acceptance is the integrity ground truth: signature plus write grant, run on every fold.
  • The may monitor is the one authorization gate, checked at the interface boundary before dispatch, for reads and writes, for every provider.
  • The Guest sandbox is a scope narrowing for untrusted foreign code (WASM apps). It confines a guest to the volumes the seat mounted; the trusted built-ins skip it because they are the shell itself.

Where a document lives, and what is inside it

These are two questions, so Lucia gives them two namespaces and one explicit boundary, the #. A Locator names where a document lives; a Path names what is inside it. A Ref joins them, so nothing is ever inferred from disk.

Locator · where
/volumes/home/scene.prisma

A tree of folders and documents. Absolute, slash-separated; filenames are ordinary segments. No slots, ever.

Path · what's inside
#/world/sphere.radius

Structure within one document: element names, anonymous [n] elements, and a .property slot. No filenames, ever.

The invariant that removes all ambiguity: a locator never contains a slot; a content path never contains a filename; the layer boundary is always the explicit #. Locator and Ref are core types beside Path. Core defines well-formedness only; resolution belongs to the layers above.

Prism names the format only

Prism is the file format: .prisma in readable ASCII, .prism in binary. The name stops there. The C++ libraries, the namespace lucia::, and the C ABI lucia_* are Lucia OS, never "Prism." The format is one thing; the software that reads and writes it is another.

The trust split

Two programs make up the running system, and the split is the whole security story. The terminal is the trusted environment: it holds your keys, does all plaintext work, signs every commit, and re-verifies the whole journal it folds. The realm is a blind relay: it holds only ciphertext it cannot read, routes what you send, and hands it back when you sync. Keys and authorship live only in the terminal, so one kernel serves both roles in two trust configurations, and the OS story and the security story are the same story.

Writing a Prism app

An app is a document you can run

A Prism app is a WebAssembly program stored as a document. Its bytes live in an app element at /content, carrying data (the wasm), abi = 2, and its own summary and usage. The document sits under /home/<user>/bin/<name>. Type a verb the shell does not build in, and it runs the matching /bin document. So installing a command is nothing more than writing a document.

The built-in commands are exactly this. Each seed (ls, cat, grep, mkdir, mv, rm, find, edit, echo, touch, jump) is a small C program compiled to wasm that speaks only the guest ABI in lucia_guest.h. No libc, no syscalls, no host paths. The compiled bytes are embedded in the terminal at build time and installed to /bin on first login, in one signed, sealed commit. The step is idempotent: a home that already has a /bin is left alone.

The run-document contract

An app never sees a process. It sees one document in and one document out. The launcher instantiates a run document with the inputs, executes the wasm confined, and parses the out document the app yields. Inputs are argv, stdin, and cwd (the shell view path, where / is home). Outputs are stdout, stderr, an exit code, and an optional cwd effect the shell applies to move the cursor. The whole run executes under a fuel and deadline meter; overrun returns a meter code, not a partial write.

// The wire the launcher speaks to your app:
def run "run" { str[] argv = [...]  str stdin = "..."  str cwd = "..." }
def out "out" { str stdout = "..."  str stderr = "..."  int exit = 0  str cwd = "..." }

The shape of a command

Every app exports lucia_main(ptr, len), reads its inputs from the run document, does its work through the ABI, and hands back an out document. This is the whole of cat, reduced to its spine:

#include "lucia_guest.h"

__attribute__((export_name("lucia_main")))
void lucia_main(u32 inPtr, u32 inLen) {
    const u8* in = (const u8*)(unsigned long)inPtr;
    char cwd[512], arg[512];
    lg_cwd(in, inLen, cwd, sizeof(cwd));    // view path, / is home
    lg_argv(in, inLen, takeArg, 0);          // pull argv[1] into arg

    char canon[512], text[32768];
    lg_out_begin();
    if (!lg_canon(cwd, arg, canon, sizeof(canon)) ||   // join + resolve mounts
        !lg_open_text(canon, text, sizeof(text))) {
        lg_out_str("stderr", "not yours to read\n");
        lg_out_int("exit", 1);
        lg_out_yield();
        return;
    }
    lg_out_str("stdout", text);
    lg_out_int("exit", 0);
    lg_out_yield();                          // hand the out document back
}

The ABI is small and named for what it does. lg_argv and lg_cwd read the run document. lg_canon joins a user argument with the cwd and resolves mounts into a canonical locator; lg_resolve and lg_display are the two directions of that map, so an app never reimplements the mount table. lg_list returns in-scope children, lg_open_text reads a document's text, lg_find matches names. lg_put_doc, lg_mkdir, lg_rm, and lg_move write. lg_out_begin / lg_out_str / lg_out_int / lg_out_yield build and return the out document. That is the entire surface.

The confinement is the point

The app runs inside a Guest, confined to exactly the roots the human consented to at launch. The app acts with your own authority, so every write is still grant-checked by the storage acceptance rule; but the Guest narrows that authority to the named roots and never widens it. Anything outside scope, anything genuinely absent, and any benign in-scope failure all collapse to a single opaque unavailable. There is no denied-versus-absent distinction to probe, so an app can tell Ok from not-Ok and nothing finer. Honest error strings would be an existence oracle; the Guest never lets one cross.

An app reaches outside its own document only by a wire, never by naming a locator.

The app names its own document freely. To touch another document it follows a connection, a wire whose declared slot type is the contract. It cannot spell a path into a subtree it was not granted, because the reference monitor answers with the same unavailable before the Session is ever called. The one frozen import, lucia_call(ptr, len), carries a single Prism request and returns a single Prism reply; that wire never widens.

Build and install your own

Compile C, or any language that targets wasm, against the ABI to a freestanding module, then bring the bytes in as a document. No network, no side channel: the document is the install medium, and no host path survives the import.

# compile against the ABI - freestanding, no libc
clang --target=wasm32 -O2 -nostdlib -fno-builtin \
      -Wl,--no-entry -Wl,--export-dynamic hello.c -o hello.wasm

# in the shell: store the bytes as an abi-2 app document under /bin
install hello.wasm /bin/hello
hello                          # an unbuilt-in verb runs /bin/hello

# or run a module directly, confined to a named scope, without installing
run hello.wasm /home/dy/notes

install writes the wasm into a document's app element with abi = 2; typing the name falls through to os::launch, which confines the Guest and runs it. run <app> <scope> names the walls at the door for a one-off. Either way the app earns exactly the roots you grant and reaches the rest of the realm through nothing but the wire.