Update 2026-06-12 — jsclaw just shipped a plugin system built from an audit of openclaw's (jsclaw/jsclaw#64, landed in jsclaw/jsclaw#77, ~100-line loader + tests). Most of the design transfers to JSS directly, and the JSS-specific parts are clarifying rather than complicating.
The reframe
JSS already is a plugin system internally: server.js composes notifications, IDP, webrtc, terminal, tunnel, ActivityPub, remoteStorage, and MCP as Fastify plugins gated by createServer({...}) flags. What this issue actually needs is the missing outer layer: third parties extending JSS without editing server.js — discovery, manifest, policy, per-plugin config.
What transfers verbatim (openclaw's vocabulary, now in jsclaw too)
Policy: deny > allow > per-entry enabled. Failing plugins warn and skip, never crash the server. Loaded ids print at boot.
The api — JSS's seams (differ from jsclaw's, but rhyme)
api.fastify — a scoped Fastify instance. Don't reinvent route/middleware registration; Fastify encapsulation already is that story, and every in-tree JSS feature proves the pattern. (This answers the "routes + middleware" part of the issue with zero new machinery.)
api.registerMcpTool(name, { description, inputSchema, handler }) — plugins extend the pod's MCP surface (the TOOLS table in src/mcp/tools.js). Sleeper feature: a CardDAV plugin (Add CardDAV plugin for contact sync with phones and desktops #157) adding contacts_* tools enriches the pod for every MCP-speaking agent.
api.storage / auth-chain helpers — the superpowers external apps currently fork JSS to reach.
One JSS-specific rule, stated loudly
Plugins load only from operator config paths — never auto-discovered from pod storage. Pods are user-writable; a loader that touches them is RCE via file upload. (openclaw's "workspace-origin plugins default-disabled" is the same instinct; JSS needs the strict version.)
Sequencing
Loader + api.fastify + policy (small; jsclaw's src/plugins.js is a working reference at ~100 lines)
Related: jsclaw/jsclaw#64 (audit of openclaw's system: sources, slots, hooks, install policy — the deferred long tail documented there applies here too).
RFC: Tideholm as plugin zero — a real third-party consumer to pin the api
The loader design above is ready to build; what it's missing is the thing jsclaw had and openclaw before it: a real external consumer developed against the seams while they land. In-tree migrations (#564) prove the mechanics but can't keep the api honest — they were all written with access to server.js internals and it shows in what they never ask for.
Proposal: port Tideholm — a zero-dependency island-strategy browser game (~7.4k lines, engine/HTTP cleanly split, 160 tests, bots that play by the same rules as humans) — as the first third-party plugin. A playable game is the most legible plugin a system can ship (the WordPress dynamic: nobody understands a platform from a WebFinger extraction; everybody understands jss install tideholm).
Bundled features sit inside the auth chain; an app sits behind it as a consumer. Tideholm drops its own password accounts in plugin mode: your pod identity is your player — auto-provisioned on first authenticated request. This is the agentic-web demo in one sentence.
api.storage.pluginDir(id)
Private server-side state (world.json, season archives). Explicitly not pod storage — consistent with the "never load/execute from pods" rule, and game state is server-authoritative, not user data.
deactivate() lifecycle
Tideholm runs interval timers (bot tick, autosave) and must save the world on teardown. First plugin to force the shutdown contract the loader will eventually need anyway.
entries.<id>.config passthrough
{ "gameSpeed": 5, "bots": 20, "freeIsles": 30 } — exercises per-plugin config with something a user actually wants to tune.
Shape (adapter is ~150 lines; engine stays zero-dep)
// jss.plugin.json{"id": "tideholm","version": "1.0.0","entry": "./plugin.js","description": "Island empire strategy game — log in with your pod"}// plugin.jsexportasyncfunctionactivate(api){constgame=createGame({dataDir: api.storage.pluginDir('tideholm'), ...api.config});awaitapi.fastify.register(async(f)=>{f.addHook('preHandler',async(req)=>{req.player=game.playerFor(awaitapi.auth.getAgent(req));// WebID -> player});registerRoutes(f,game);// existing route table, transport-agnostic already});// + static assets under /tideholm/game.start();// bot tick + autosave intervalsreturn{deactivate: ()=>game.stop()};// saves world, clears timers}
Standalone node server.js keeps working — the game keeps its zero-dependency core and gains a thin adapter, so it's also a living example of "app that runs alone or as a plugin".
Sequencing (slots into #564's plan without reordering it)
Loader + api.fastify + policy land as designed above
Tideholm proves the third-party path — and it can start today as a plain Fastify plugin (the bitmark-explorer pattern), flipping to the manifest the day the loader merges
Once landed, the follow-ups write themselves
Battle reports as JSON-LD into the player's pod (opt-in) — game history you own
Public hall-of-fame as an LDP resource; wonder victories announced over the pod's Nostr relay
I'll build the adapter against whatever the loader lands with — treat this as a standing offer of a plugin-zero test consumer for every api decision above.
Plugin zero is running: a pod identity plays Tideholm inside JSS
Follow-up to the RFC above — the adapter is built, and the end-to-end works today: melvincarvalho/tideholm jss-plugin/ (adapter + jss.plugin.json manifest + runnable demo.mjs).
boots a real JSS (IdP on), registers a pod account over HTTP, mints a Bearer token from /idp/credentials, and then — all 12 checks green:
anonymous GET /tideholm/api/state → 401 from the game's own auth, not WAC
same request with the token → player auto-provisioned from the WebID (alice), starting island and all
POST /tideholm/api/build flows through the whole stack (JSS auth → adapter → game engine)
game client served at /tideholm/, LDP still serving alice's profile on the same origin
second pod (bob) → second player; the game world is keyed by WebID (player.extId)
The adapter is ~100 lines of glue, exports both contracts from the RFC — tideholmApp() for today's plain-Fastify composition and activate(api) for the loader — and the game core stayed zero-dependency.
Two things plugin zero forced that the loader design above doesn't cover yet
1. Plugins need WAC-hook exemption. The global preHandler authorizes every URL against pod ACLs and rejects before plugin routes run — a plugin's POSTs die in WAC unless its prefix is on the hook's hardcoded exempt list (which is how /storage/, /db/, /mcp etc. escape today). The demo runs on this 8-line seam:
@@ createServer options @@+ // Application mount points (plugin seam, #206): URL prefixes owned by+ // registered apps (e.g. a game mounted at /tideholm). Requests below an+ // app path skip the WAC hook — the app owns authentication and+ // authorization under its prefix, like /storage/ and /db/ already do.+ const appPaths = Array.isArray(options.appPaths)+ ? options.appPaths.filter((p) => typeof p === 'string' && p.startsWith('/') && p.length > 1)+ : [];@@ the WAC preHandler exempt condition @@+ appPaths.some(p => request.url === p || request.url.startsWith(p + '/') || request.url.startsWith(p + '?')) ||
For the loader: api.fastify should carry this implicitly — a plugin's declared prefix is its jurisdiction. (appPaths also stands alone as a useful createServer option for the bitmark-explorer-style compositions that exist now. Happy to PR it.)
2. Plugins that read raw bodies need a content-parser escape. Fastify's JSON parser consumes the request stream before the handler runs, so a plugin that proxies to its own node-style handler hangs waiting for a body that's already been eaten. The fix is the standard proxy pattern, scoped so it can't affect the host:
The loader's api.fastify should offer this as a mode (rawBody: true or similar) — any plugin wrapping an existing HTTP app will hit it.
Identity turned out to need no new seam at all: getWebIdFromRequestAsync(request) from src/auth/token.js already does Bearer/DPoP/NIP-98/LWS-CID uniformly — it just needs to be blessed as public api (api.auth.getAgent), not reached into.
✅ v0.0.214 — #584 / #586. Pre-loader shape: import { getAgent } from 'javascript-solid-server/auth.js'; when the loader lands, api.auth.getAgent is this function
Plugin zero (Tideholm) migrates onto the public getAgent import once 0.0.214 hits npm, at which point the reference plugin uses zero internal imports — everything it touches is either the returned fastify instance, appPaths, or auth.js. The loader is now genuinely the only missing piece between "compose it yourself" and jss start with a plugins config.
Seam three, from the first realtime consumer (bridge, live at https://nostr.social/bridge/): WebSocket upgrade routing — #588. HTTP mounting (#582) and auth (#584) composed unchanged; upgrades are the one thing Fastify never shows a plugin, plus a destroy-unclaimed-sockets invariant someone must own. Proposal there is a ~15-line api.ws.route(prefix, handler) on the loader api.
The loader shipped: createServer({ plugins: [{ module, prefix, config, id }] }) — PR #589, merged as c516bb6, version 0.0.215.
Each module's activate(api) runs at startup with the seams this exercise forced, in the order they were forced: the entry's prefix auto-joins appPaths (#582, 0.0.213), api.auth.getAgent verifies any host credential (#584, 0.0.214), api.ws.route gives realtime apps WebSocket endpoints without owning an 'upgrade' listener (#588), plus api.storage.pluginDir() and dual-dialect logging. Docs: configuration.md → App Plugins.
Validated the plugin-zero way: both real consumers (Tideholm and bridge) compose from pure config in one server — one pod account plays both games, bridge over the loader-owned WebSocket.
Remaining from this RFC's scope, deliberately deferred: a plugins block in the CLI config file (makes 'install an app = edit config' real for non-programmatic deployments), #583 raw-body mode, #564 migrating the bundled features onto the loader, and UI-pane discovery (still no consumer needing it).
Activity
melvincarvalho commented on Jun 11, 2026
Update 2026-06-12 — jsclaw just shipped a plugin system built from an audit of openclaw's (jsclaw/jsclaw#64, landed in jsclaw/jsclaw#77, ~100-line loader + tests). Most of the design transfers to JSS directly, and the JSS-specific parts are clarifying rather than complicating.
The reframe
JSS already is a plugin system internally: server.js composes notifications, IDP, webrtc, terminal, tunnel, ActivityPub, remoteStorage, and MCP as Fastify plugins gated by createServer({...}) flags. What this issue actually needs is the missing outer layer: third parties extending JSS without editing server.js — discovery, manifest, policy, per-plugin config.
What transfers verbatim (openclaw's vocabulary, now in jsclaw too)
{ "plugins": { "enabled": true, "allow": null, "deny": [], "load": { "paths": ["./my-plugin"] }, "entries": { "my-plugin": { "enabled": true, "config": { } } } } }The api — JSS's seams (differ from jsclaw's, but rhyme)
One JSS-specific rule, stated loudly
Plugins load only from operator config paths — never auto-discovered from pod storage. Pods are user-writable; a loader that touches them is RCE via file upload. (openclaw's "workspace-origin plugins default-disabled" is the same instinct; JSS needs the strict version.)
Sequencing
Related: jsclaw/jsclaw#64 (audit of openclaw's system: sources, slots, hooks, install policy — the deferred long tail documented there applies here too).
melvincarvalho commented on Jul 10, 2026
RFC: Tideholm as plugin zero — a real third-party consumer to pin the api
The loader design above is ready to build; what it's missing is the thing jsclaw had and openclaw before it: a real external consumer developed against the seams while they land. In-tree migrations (#564) prove the mechanics but can't keep the api honest — they were all written with access to server.js internals and it shows in what they never ask for.
Proposal: port Tideholm — a zero-dependency island-strategy browser game (~7.4k lines, engine/HTTP cleanly split, 160 tests, bots that play by the same rules as humans) — as the first third-party plugin. A playable game is the most legible plugin a system can ship (the WordPress dynamic: nobody understands a platform from a WebFinger extraction; everybody understands jss install tideholm).
The seams it forces that #564's migrations won't
Shape (adapter is ~150 lines; engine stays zero-dep)
Standalone node server.js keeps working — the game keeps its zero-dependency core and gains a thin adapter, so it's also a living example of "app that runs alone or as a plugin".
Sequencing (slots into #564's plan without reordering it)
Once landed, the follow-ups write themselves
I'll build the adapter against whatever the loader lands with — treat this as a standing offer of a plugin-zero test consumer for every api decision above.
Refactor: transport-agnostic app core with pluggable identity
melvincarvalho commented on Jul 10, 2026
Plugin zero is running: a pod identity plays Tideholm inside JSS
Follow-up to the RFC above — the adapter is built, and the end-to-end works today: melvincarvalho/tideholm jss-plugin/ (adapter + jss.plugin.json manifest + runnable demo.mjs).
boots a real JSS (IdP on), registers a pod account over HTTP, mints a Bearer token from /idp/credentials, and then — all 12 checks green:
The adapter is ~100 lines of glue, exports both contracts from the RFC — tideholmApp() for today's plain-Fastify composition and activate(api) for the loader — and the game core stayed zero-dependency.
Two things plugin zero forced that the loader design above doesn't cover yet
1. Plugins need WAC-hook exemption. The global preHandler authorizes every URL against pod ACLs and rejects before plugin routes run — a plugin's POSTs die in WAC unless its prefix is on the hook's hardcoded exempt list (which is how /storage/, /db/, /mcp etc. escape today). The demo runs on this 8-line seam:
For the loader: api.fastify should carry this implicitly — a plugin's declared prefix is its jurisdiction. (appPaths also stands alone as a useful createServer option for the bitmark-explorer-style compositions that exist now. Happy to PR it.)
2. Plugins that read raw bodies need a content-parser escape. Fastify's JSON parser consumes the request stream before the handler runs, so a plugin that proxies to its own node-style handler hangs waiting for a body that's already been eaten. The fix is the standard proxy pattern, scoped so it can't affect the host:
The loader's api.fastify should offer this as a mode (rawBody: true or similar) — any plugin wrapping an existing HTTP app will hit it.
Identity turned out to need no new seam at all: getWebIdFromRequestAsync(request) from src/auth/token.js already does Bearer/DPoP/NIP-98/LWS-CID uniformly — it just needs to be blessed as public api (api.auth.getAgent), not reached into.
bump version to 0.0.213
feat(auth): public getAgent accessor — request → WebID (#584) (#586)
bump version to 0.0.214
melvincarvalho commented on Jul 10, 2026
Seam scoreboard update:
Plugin zero (Tideholm) migrates onto the public getAgent import once 0.0.214 hits npm, at which point the reference plugin uses zero internal imports — everything it touches is either the returned fastify instance, appPaths, or auth.js. The loader is now genuinely the only missing piece between "compose it yourself" and jss start with a plugins config.
melvincarvalho commented on Jul 10, 2026
Seam three, from the first realtime consumer (bridge, live at https://nostr.social/bridge/): WebSocket upgrade routing — #588. HTTP mounting (#582) and auth (#584) composed unchanged; upgrades are the one thing Fastify never shows a plugin, plus a destroy-unclaimed-sockets invariant someone must own. Proposal there is a ~15-line api.ws.route(prefix, handler) on the loader api.
feat(plugins): createServer({ plugins }) — the #206 loader (#589)
bump version to 0.0.215
melvincarvalho commented on Jul 10, 2026
The loader shipped: createServer({ plugins: [{ module, prefix, config, id }] }) — PR #589, merged as c516bb6, version 0.0.215.
Each module's activate(api) runs at startup with the seams this exercise forced, in the order they were forced: the entry's prefix auto-joins appPaths (#582, 0.0.213), api.auth.getAgent verifies any host credential (#584, 0.0.214), api.ws.route gives realtime apps WebSocket endpoints without owning an 'upgrade' listener (#588), plus api.storage.pluginDir() and dual-dialect logging. Docs: configuration.md → App Plugins.
Validated the plugin-zero way: both real consumers (Tideholm and bridge) compose from pure config in one server — one pod account plays both games, bridge over the loader-owned WebSocket.
Remaining from this RFC's scope, deliberately deferred: a plugins block in the CLI config file (makes 'install an app = edit config' real for non-programmatic deployments), #583 raw-body mode, #564 migrating the bundled features onto the loader, and UI-pane discovery (still no consumer needing it).
fix(cli): forward plugins and appPaths from config to createServer (#592
bump version to 0.0.216
bump version to 0.0.217
feat(plugins): api.mountApp for wrapped node-style apps (#583) (#590)
fix(server): dotfile guard defers to app plugin prefixes (#206)
feat(server): appPaths — WAC-exempt application mount points (JavaScr…
bump version to 0.0.220