dox docs

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:

  1. Skips it unless dist/index.html exists. A folder without a build is invisible.
  2. Reads manifest.json if present; every field is optional.
  3. Mounts dist/ at /app/<id>/ and adds a single-page fallback for non-API GET requests.
  4. If backend is "router", imports server/index.ts and mounts its exported router at 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

  1. mkdir app/notes

  2. Add a front end (index.html, or a Vite project with npm run build writing dist/).

  3. Add manifest.json:

    { "name": "Notes" }
    
  4. Optional backend at server/index.ts exporting router, and "backend": "router" in the manifest.

  5. Build it: cd app/notes && npm run build.

  6. 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.