Architecture
dox is four layers stacked so that each one can be replaced without touching the others:
app/ features, one folder each
event/ a graph of named events between apps
icanvas/ the canvas, the windows, the keyboard
system/ one process that mounts everything
Each layer only knows the one beneath it. An app does not know the canvas; the canvas does not know what an app does; the server does not know what an app renders.
The four layers
| Layer | Owns | Does not own |
|---|---|---|
app/ |
One feature: its front end, its optional router, its manifest.json, its dependencies. |
Other apps, the window chrome, routing between apps. |
event/ |
The edge list (graph.json) and a 40-line SDK with call and on. |
What the payloads mean. |
icanvas/ |
Camera, windows as iframes, selection, keyboard, saved scenes. | Anything inside a window. |
system/ |
Directory scan, manifest parsing, mounting routers and static files, one origin. | Layout, interaction, feature code. |
Why apps stand alone
Every app has its own package.json, its own build and its own dependencies. No bundler config is shared; no package is hoisted across apps. Editor can be React, Wallpaper can be a single HTML file, and neither knows.
This costs disk space and duplicates a little config. It buys one property: an app can leave. Copy app/editor/ into another project, run npm install && npm run build, and it still works. Delete it here and the rest of the workspace loads exactly as before.
That property is why there is no "plugin API": the manifest is the whole contract, and the filesystem is the registry.
Why events instead of imports
An app that wants to open a file does not import the editor. It emits an event:
event.call('fileClick', { path });
event/graph.json decides what happens:
{ "from": { "app": "explorer", "event": "fileClick" },
"to": { "app": "editor", "call": "openFile" } }
Three consequences:
- Explorer has no idea the editor exists. It can be used with the editor removed.
- Rewiring behaviour is a JSON edit, not a code edit.
- The emitter cannot be broken by a listener: unbound events are silent no-ops.
Why one HTML file for the canvas
icanvas/index.html is a single file with no dependencies, about 1,700 lines. It draws windows, pans, zooms in steps from 50% to 200%, handles selection and the keyboard, and saves layouts.
Windows are <iframe> elements. That choice is doing real work:
- Each app runs in its own document with its own globals, so two apps can both define
Reactorfetchwrappers without colliding. - An app's CSS cannot leak into the canvas or into another app.
- Same origin, so
postMessageis cheap and an app'sfetch('api/x')hits its own backend without CORS.
The cost is that focus and keyboard input have to be passed across the iframe boundary, which is why some shortcuts are handled by each app and some by the container.
Why one process
Every app's backend is a router mounted into one Node process, not a separate server. That is why the workspace costs what it costs: about 95 MB for the server and roughly 140 MB including a browser tab, versus 1.6 GB for an Electron editor.
The trade-offs are deliberate:
- One origin. No CORS, no per-app ports, no proxy table.
- One crash domain. A backend that throws at import time is caught, logged and skipped; that app falls back to static. An app that throws at runtime can still take the process down — the same risk a monolith has.
- No per-app resource limits. There is no sandbox between apps.
If you need hard isolation between two apps, run a second dox on another port; nothing in the data model prevents it.
Data is files
| Thing | Where | Format |
|---|---|---|
| Conversation | dox-data/agent/sessions/*.sss |
JSON: title, turns, tool calls, timestamps |
| Layout | *.dox |
JSON: camera, plus one entry per window |
| Settings | dox-data/agent/config.json |
default model, default cwd |
| Models | dox-data/agent/models/*.env |
endpoint, key and model name, one file per model |
Where the data directory goes
Three requirements pick the location, in this order:
- On the persistent volume. In a container or on a cluster, everything outside the mounted volume disappears on restart — the root filesystem and
$HOMEincluded. - Outside the code directory. Replacing the code tree, or syncing it with
rsync --delete, must not touch the data. - Derivable from the code path. If the location needs an environment variable or a remembered absolute path, every deployment has to be told, and "copy the folder and run" stops being true.
A sibling directory satisfies all three with no configuration: dirname(REPO_ROOT) + "/dox-data".
Every alternative fails one of them:
| Location | Fails |
|---|---|
~/.local/share/dox |
1 — home is often ephemeral on clusters |
<code>/.data |
2 — replaced or deleted with the code |
/var/lib/dox |
3 — needs DOX_DATA_DIR on every deployment |
The dox- prefix earns its keep twice: it never collides with a sibling project's data/, and it stays correct when the code directory is renamed — /srv/dox-2026-10/ still reads /srv/dox-data/.
DOX_DATA_DIR remains the escape hatch for the layout that breaks rule 1 by design: code baked into an image, data volume mounted somewhere else.
No database, no migrations. A conversation is a document the agent app knows how to render — the same relationship the editor has with source files and the image app has with bitmaps. That is what "AI as a file type" means here: the agent is not a panel bolted to the side, it is a renderer registered for an extension.
What is deliberately absent
- LSP, debuggers, an extension marketplace. The editor is a text editor. If you need those, run them beside dox, not inside it.
- Authentication. The server binds
127.0.0.1and assumes a trusted local user. Put a reverse proxy with auth in front (see Deploy). - A build pipeline for the server. The server imports
.tsdirectly through Node's type stripping. This is why Node 22.6 is the floor.