manifest.json
Every app is a folder under app/. The folder's manifest.json is the whole contract between the app and the server — there is no plugin API beyond it.
How an app is discovered
On boot the server scans app/*/ and, for each directory:
- Skips it unless
dist/index.htmlexists. A folder without a build is invisible. - Reads
manifest.jsonif present; every field is optional. - Mounts
dist/at/app/<id>/and adds a single-page fallback for non-API GET requests. - If
backendis"router", importsserver/index.tsand mounts its exportedrouterat the same path.
Nothing is registered centrally. Adding a folder is adding an app.
Fields
| Field | Type | Default | Meaning |
|---|---|---|---|
name |
string | folder name | Display name in the app menu. |
entry |
string | app/<id>/index.html |
Front-end entry the container loads in a window. |
backend |
"router" |
— | Import server/index.ts and mount its router. Omit for a purely static app. |
hidden |
boolean | false |
Keep it out of the app menu and out of /api/app, while still mounting it. search uses this: it lives in the bottom bar, not the menu. |
renders |
object | — | Declares that this app renders a file type. See below. |
renders
The explorer asks GET /api/app for the registry and maps file extensions to apps with it.
| Key | Meaning |
|---|---|
extensions |
Array of extensions this app opens, e.g. [".sss"]. |
meta |
Endpoint on this app that returns { title, count, created, updated } for a file. The explorer calls it as /app/<id><meta>?path=… to draw the row. |
event |
Event name the explorer fires when the row is double-clicked. The event graph decides who handles it. |
The agent's manifest is the full example:
{
"name": "Agent",
"backend": "router",
"renders": {
"extensions": [".sss"],
"meta": "/api/session-meta",
"event": "sessionClick"
}
}
The router contract
A backend app exports a router from server/index.ts. The router is the 260-line one shipped in server/http.ts — each app carries its own copy, so the folder stays copyable:
import { createRouter } from './http.ts';
export const router = createRouter();
router.get('/api/hello', (_req, res) => {
res.json({ ok: true });
});
The contract is only "an object with handle(req, res, base, next)"; anything satisfying that can be mounted.
Routes are written without the mount prefix. With the app in app/notes/, the route above answers at /app/notes/api/hello.
The server also serves dist/ at that prefix, so the front end can call its own backend with relative URLs:
fetch('api/hello'); // resolves inside the iframe's own /app/notes/ base
If the import throws, the server logs a warning and falls back to static-only for that app. One broken backend does not stop the workspace from booting.
Wiring two apps together
Apps never call each other. They emit named events, and event/graph.json maps an event to a call on another app:
[
{ "from": { "app": "explorer", "event": "fileClick" }, "to": { "app": "editor", "call": "openFile" } },
{ "from": { "app": "explorer", "event": "sessionClick" }, "to": { "app": "agent", "call": "loadSession" } }
]
On the emitting side — event.call is a no-op unless an edge consumes it:
event.call('sessionClick', { path });
On the receiving side:
event.on('loadSession', (payload) => openSession(payload.path));
The container reads the graph, so an app that is not running simply does not receive the call. Rewiring two apps is one line of JSON and no code change on either side.
Adding an app, end to end
mkdir app/notesAdd a front end (
index.html, or a Vite project withnpm run buildwritingdist/).Add
manifest.json:{ "name": "Notes" }Optional backend at
server/index.tsexportingrouter, and"backend": "router"in the manifest.Build it:
cd app/notes && npm run build.Restart dox. The app appears in the right-click menu.
To make the explorer open a new file type, add renders to the manifest and an edge to event/graph.json.