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, andpasswdchanges 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.
cd move (~ home, - back) · pwd show it · at <version> time-travel the tree · now return to head.
ls (-l -a -R) · cat · put <path> <text…> · mkdir · touch · rm · mv · edit <path> [-w <text…>] · grep (-i -l) · find.
share <path> with <user> seals a file to a reader · grant <who> <path> <right> and revoke pass or drop access.
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 ls · service deploy <kind> … · service rm <name> · service call <name> <verb> [args].
log [n] · versions <path> · diff <v1> [v2] <path> · sync squares with the set remote · push / pull one direction only.
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.
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
- 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
- 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:
closedaccepts no allocations.inviterequires a one-time code.openlets 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 fromkeys/<founder>.enc. The plaintext code is shown once; the realm stores only its digest. - Registering a user writes only their public keys (
registerUserlays 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.
Products. The trusted seat, the blind relay, and the lucia CLI. They link only Lucia::Os (the CLI links core).
The facade. Session, Service, Point, Guest, WasmHost: the one door products reach the realm through, and the one namespace interface every command runs on.
The agent library: OpenRouter client (streaming, tool calls), agent loop, the permission gate, harness / web / session tools, /commands, sessions in .prisma, compaction, MCP core.
Principals, tokens, grants, contact cards, sealed delivery. Consumes crypto; core does not.
The signed journal and the one acceptance rule. Its value-add is signing and acceptance, not a second document model.
HTTP / TLS transport, WebSocket, the SDUI channel, the federation wire.
The document-native window and SDUI face, over a portable windowing and GPU RHI abstraction (macOS Metal first, Vulkan / GL cross-platform).
Monocypher-based sealing and signing. The lowest layer, with no document model. Consumed by auth, never by 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 includelucia/os/*andlucia/net/*only; the facade re-exports what they need. Each product'stest.shgreps for header leaks. - lucia-core is crypto-free. Core operates on documents whose property values are
opaque strings, so
diff,merge, andconflictsbehave identically on sealed and cleartext values. Sealing lives inlucia-crypto, consumed bylucia-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(), andcompact()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-agnostic │ one lucia-os namespace interface list / open / read / stat / walk / write │ permission 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
maymonitor 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.
A tree of folders and documents. Absolute, slash-separated; filenames are ordinary segments. No slots, ever.
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.