Extensions

An extension is a small JavaScript program that returns an HTML page. It runs in a sandboxed QuickJS isolate, in its own process, behind a strict content policy — so you can add an app to your workspace without touching the server.

Overview

An extension is a small JavaScript program that returns an HTML page, which Suwu renders inside a tile. Extensions are how you add an app to Suwu without touching Go or React: a script runs in a sandboxed gqjs QuickJS isolate — in its own process, with a deadline — and hands back a document.

Three example extensions ship in the repository, and they are the ones the install catalog offers:

Example Shows
eye The smallest possible extension: a render stub plus a static asset, with configuration passed to the page through a data- attribute.
note A pure client-side app — seven numbered sticky notes persisted in the browser's IndexedDB through the storage bridge, with no API and no network.
hn-top-stories The full stack: an API route, a server-side fetch, an ES-module tree of static assets, and an htmx poll of the extension's own API.

Installing an Extension

Suwu never installs extensions behind your back. From a terminal, let suwu install do it:

# open the picker (needs a terminal)
suwu install

# or name them outright
suwu install --github eye note hn-top-stories

# see what is on offer without installing
suwu install --list

# or install from a zip you were sent
suwu install --extension eye.zip

Everything lands in $SUWU_VAR/extension/<id>/ (default ~/.suwu/extension/) and is live from the server immediately — the extension registry is read from disk per request, so nothing needs a restart. The tab notices on its own the next time you open System Settings, which re-reads the list. Installing over an existing id needs --force; --dry-run reports what would be written and changes nothing.

The same works from a checkout: zip one extension so the archive root is extension/<id>/… (a zip holding a single bare <id>/… directory is accepted too), or copy the directory by hand:

cp -r examples/extension/eye ~/.suwu/extension/
The directory was renamed

The data directory used to be ~/.suwu/extensions and is now ~/.suwu/extension, matching background. The old name is still read when it holds extensions, so nothing disappears on upgrade — but suwu install only ever writes the new path. Move it yourself when convenient: mv ~/.suwu/extensions ~/.suwu/extension.

A directory becomes an extension as soon as it contains a valid package.json and an index.js:

<dataDir>/extension/<id>/
  package.json     # metadata — required
  index.js         # render entry point — required, fixed name
  public/          # client assets, when suwu.static declares it
  *.js             # API handlers registered in suwu.api

The id is the directory name, and it must match ^[a-z0-9][a-z0-9_-]{0,63}$. The directory is also the extension's read-only /ext/<id> filesystem root, so an extension can bundle data next to its script.

Adding an Extension Tile

The Extension tile is off by default — a fresh install does not advertise a tile with nothing configured. To use one:

  1. Open the App Menu and enable the Extension app.
  2. Add a Custom App, choose the Extension plugin, and set the id parameter to the directory name (for example eye).
  3. Add your own parameters if the extension declares any; they are passed straight through to the script.
  4. Place the app in the grid like any other tile.

A tile with no id shows a short hint instead of a blank frame — there is no in-tile picker.

Metadata: package.json

npm-style fields live at the top level, and everything Suwu-specific lives under the suwu object. name falls back to the directory id, and everything except the file itself is optional:

{
  "name": "Hacker News Top Stories",
  "description": "The Hacker News front page as a broadsheet carousel",
  "suwu": {
    "static": "public",
    "net": true,
    "params": [
      { "key": "size", "label": "Size", "description": "Eye radius in px", "defaultValue": "140" }
    ],
    "api": [
      { "route": "/stories", "handler": "stories.js" }
    ]
  }
}
Key Meaning
name / description Human-readable identity, shown in the App Menu.
suwu.params Documents the launch parameters so the App Menu can offer them. Extra query parameters are passed through regardless.
suwu.static A directory of client assets served at /gqjs/static/<id>/. Must be a dedicated subtree — never . or the extension root.
suwu.api Ordered route registrations, each { route, handler }.
suwu.net Opt-in to the network: enables fetch() inside the script.

Unknown fields (version, dependencies, …) are ignored. A malformed package.json fails the extension outright, so a broken registration can never half-serve.

Writing index.js

Define a handler function. It receives the request input and returns either an HTML string or an object:

function handler(input) {
  const size = input.params?.size ?? "140";
  return {
    status: 200,                             // optional
    contentType: "text/html; charset=utf-8",  // optional
    body: `<!doctype html><html><body>
      <canvas data-size="${size}"></canvas>
    </body></html>`,
  };
}

The input carries the action ("render" or "api"), the extension id, the pane id, method, path, query, headers, and your tile parameters — plus token, the extension-scoped token described below. A non-2xx status is honoured; an invalid result produces a minimal error page and a server-side log line, never a stack trace in the browser.

The script runs once per request to produce the document — animation, pointer tracking, and everything else happens client-side in the page it returns.

API Routes

Registered routes are served under /gqjs/api/<id>/<path> and are a pure data relay: the handler's result is returned byte-for-byte, never injected into a page.

  • Routes are matched in declaration order; the first match wins, with a segment-wise prefix match. /hello therefore shadows /hello/world.
  • <name> segments bind and are passed through as pathParams: /note/<id> matches /note/42 and /note/42/edit with id=42.
  • / is the catch-all root. No registration, no match, or a missing handler file all answer 404 — the API fails closed.
  • Methods are limited to GET, HEAD, POST, PUT, PATCH, and DELETE; bodies are capped at 4 MiB, and a script that overruns its deadline returns 504.

Handlers see the request as JSON: route, path, pathParams, method, query, lowercased headers, and the body as text (or base64 when it is not valid UTF-8). They run through the same isolated child process as renders — read-only root, minimal environment, no network unless suwu.net says so.

Static Assets

"suwu": { "static": "public" } serves a directory at /gqjs/static/<id>/<path> for GET/HEAD, with an extension allow-list, no directory listings, no dotfiles, no traversal or symlink escapes, and an 8 MiB cap. Documents (HTML, SVG) are served with a strict default-src 'none' policy so opening one directly renders it inert.

Static files are public

ES-module fetches from a sandboxed, opaque-origin page carry neither cookies nor tokens — a module's relative imports cannot carry one either. That is why /gqjs/static/ is unauthenticated. Never put a secret under public/. Credentials travel through the authenticated render HTML only: an inline classic script or a data-* attribute such as data-api="…?token=…", read by the static code at runtime.

Network Access

By default the script is networkless: fetch() rejects with network access is disabled. Set "suwu": { "net": true } to opt in. Even then, loopback, private, and link-local addresses (cloud metadata endpoints) stay blocked — a networked extension cannot reach your internal services from the server side.

Storage, Focus, and Keyboard

  • Storage bridge — a sandboxed page has an opaque origin, so it has no web storage of its own. The note example persists through the trusted tile page instead, which stores values in its own IndexedDB under a key namespaced to that extension. The child chooses only a suffix, so it can never reach another extension's data, app settings, or the session.
  • Focus and shortcuts — key events stop at frame boundaries, so Suwu injects a small relay into the rendered page: it reports focus changes and keystrokes up to the tile, and swallows the browser defaults for the families it forwards. Alt shortcuts work inside an extension tile exactly as they do in a terminal tile.
  • Links — target="_blank" opens a normal tab with a real origin, and the page's own sandbox policy keeps such a document from reading the session cookie.

Security Model in One List

  1. The app shell is authenticated with the session token. An extension is handed a token scoped to its own API only — it opens /gqjs/api/<id>/* and nothing else. An extension token is rejected everywhere under /api/*, and the session token is never issued to extension code.
  2. Ids, paths, and handler files are validated and re-checked against the extension directory, symlinks included.
  3. Every script runs as a fresh child process with a read-only root, a minimal environment, a deadline, and a concurrency cap — a crash or a runaway script cannot take down the server.
  4. Rendered pages are served with a strict CSP, nosniff, and no-store, inside a sandboxed frame that is forced to an opaque origin.
  • gqjs Script Runner — the runtime behind extensions, usable on its own from the command line.
  • Backgrounds — the same pattern applied to WebGPU backgrounds.