Build a plugin.
Two kinds of extension, one mechanism: a pane plugin renders its own UI inside a pane; an automation runs unattended in the background when a file changes. Both are just a JSON manifest plus an HTML file you host yourself — nothing to upload, nothing to build, nothing FileStage needs to trust in advance.
How it works
A plugin runs in an <iframe sandbox="allow-scripts">
— deliberately without allow-same-origin,
so it gets a genuinely opaque origin: no access to the host page's DOM, cookies, or
localStorage, even though it's served from
the same domain. The only channel in or out is
postMessage.
That channel speaks the exact same JSON-RPC 2.0 shape the Adapter itself uses over WebSocket — if you've read a plugin's manifest or poked at the app's network tab, you already know the shape. Every call your plugin makes is checked against a fixed list of permissions you declared in your manifest — anything not on that list is rejected before it reaches a real handler. That permission list is also exactly what the person adding your plugin sees and reviews, before they ever enable it.
Your plugin can't see anything about the host page, can't read other tabs, can't touch
localStorage or cookies, and can't do
anything the bridge doesn't explicitly expose. That's not a suggestion — it's enforced by the browser's
own sandbox attribute, not by your plugin choosing to behave.
The manifest
A pane plugin's manifest is a plain JSON file:
{
"id": "com.example.my-plugin",
"displayName": "My Plugin",
"specVersion": 1,
"entry": "index.html",
"permissions": ["fs.list", "tree.stage"]
}
id— anything unique to you; reverse-DNS style is a reasonable convention, not a requirement.displayName— shown to the person reviewing your plugin before they enable it.specVersion— always1today.entry— the HTML file the sandboxed iframe actually loads, resolved relative to the manifest's own URL.permissions— the exact bridge methods you're asking for. Nothing not on this list will ever be reachable, no matter what your code calls.
The bridge protocol
Your entry document talks to the host by posting JSON-RPC request objects to
window.parent, and listening for the
matching response on window's own
message event:
// Request (you send this)
{ "jsonrpc": "2.0", "id": 1, "method": "fs.list", "params": { "path": "/home/you" } }
// Response (you receive this)
{ "jsonrpc": "2.0", "id": 1, "result": [ ... ] }
// or
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "..." } }
Your plugin can't assume a build step — you can't import
anything from the host app, so the small request/response bookkeeping (tracking an incrementing
id, matching replies to pending promises)
has to live in your own script. It's about fifteen lines — see the full example below.
Available methods
| Method | Params | What it does |
|---|---|---|
| fs.list | { path } | Lists one directory (non-recursive). Returns an array of nodes with name, path, kind ("file"/"directory"), and size. |
| tree.stage | { type, sourcePath, destPath? } | Stages a move/copy/delete/rename into the human's own Execution Preview — proposes an action, never applies one. The person using the app still reviews and applies it themselves. |
| execution.run | { type, sourcePath, destPath? } | Runs a real move/copy/delete immediately, no human review step. Only meaningful for automations — see below. Never combine this with a pane plugin's UI unless you genuinely mean "no confirmation, ever." |
| auth.requestDirectAccess | {} | Mints a short-lived, narrowly-scoped credential and returns a ready-to-use WebSocket URL your plugin can connect to directly — see "Direct Adapter access" below. |
Ask for exactly what you need. A method you didn't declare in permissions
comes back as the same generic "method not found" error a genuinely nonexistent method would — there's
no way to probe for what you're missing.
A full working example
This is the entire client side of a real plugin that lists a folder and stages deletes — no build step, plain vanilla JS:
<script>
(function () {
let nextId = 1;
const pending = new Map();
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || typeof data.id !== 'number') return;
const waiter = pending.get(data.id);
if (!waiter) return;
pending.delete(data.id);
if (data.error) waiter.reject(new Error(data.error.message));
else waiter.resolve(data.result);
});
function call(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
});
}
// List a folder:
call('fs.list', { path: '/home/you' }).then((nodes) => {
console.log(nodes); // [{ name, path, kind, sizeBytes, ... }, ...]
});
// Stage a delete (the human still applies it themselves):
call('tree.stage', { type: 'delete', sourcePath: '/home/you/old.txt' });
})();
</script>
Want the whole file, manifest included? The real example this page's code is copied from ships with the app itself and is live right now: http://localhost:5173/example-plugin/manifest.json. Paste that URL into "Add a Plugin" in the app (see "Testing it" below) to see it running for real.
Automations
An automation is the same mechanism, hidden instead of visible (it runs in an off-screen iframe, not
a pane you look at), plus one extra manifest field — trigger
— describing what wakes it up:
{
"id": "com.example.move-txt-to-processed",
"displayName": "Move new .txt files to processed/",
"specVersion": 1,
"entry": "index.html",
"permissions": ["execution.run"],
"trigger": {
"root": "/path/to/watch",
"events": ["created"],
"pattern": "*.txt"
}
}
trigger.root is the folder to watch (it gets scanned automatically
once your automation is enabled, whether or not anyone has it open in a pane).
events filters by change type (omit it for "every type").
pattern is a tiny */?
glob matched against the changed file's name — never a directory.
When a match fires, your script receives a fire-and-forget notification instead of a request — same
shape, no id:
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.method !== 'automation.triggered') return;
const { path, isDir, type } = msg.params.event;
// ... call('execution.run', { type: 'move', sourcePath: path, destPath: ... })
});
A real hazard worth knowing about before you ship one:
If your automation's own action creates a new file matching its own trigger, under the same
watched root, it will fire on its own output — the same way a database trigger that writes back
to its own watched table can. This was found live during development: an early version of the
example automation above nested eleven levels of processed/processed/processed/...
folders in about four seconds. Guard against it — either write your output outside the watched
root, or explicitly skip paths that already look like your own output (the real example does the
latter: if (event.path.includes('/processed/')) return;).
Direct Adapter access
The bridge above round-trips every call through the host page. That's fine for occasional calls, but
if your plugin needs to do a lot of work — many calls in a row, or something long-running — that
round trip adds up. If your manifest declares "auth.requestDirectAccess",
you can ask for a credential that lets you open your own WebSocket connection straight to the
Adapter, scoped to exactly your other declared permissions — never more:
const { wsUrl } = await call('auth.requestDirectAccess', {});
const socket = new WebSocket(wsUrl);
socket.addEventListener('open', () => {
let id = 1;
socket.send(JSON.stringify({ jsonrpc: '2.0', id: id++, method: 'fs.list', params: { path: '/home/you' } }));
});
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
// same JSON-RPC shape — id-keyed responses, no id on server-pushed notifications
});
The credential is short-lived (an hour by default) and mechanically restricted server-side to exactly the methods your manifest declared — even if your own code tried to call something else over that connection, the Adapter itself rejects it. This isn't a bigger trust grant than the regular bridge, just a faster path for the exact same permissions.
Testing it
- Host your
manifest.jsonand entry file anywhere reachable over HTTP(S) — GitHub Pages, your own server, or just a local static file server while you're developing. - Open the app, connect to an Adapter.
- For a pane plugin: open the volume picker → "Plugins (experimental)" → paste your manifest URL → Preview → review the permissions it's requesting → Enable.
- For an automation: Settings → Automations → paste your manifest URL → Preview → Enable.
- Either way, the exact permissions your manifest declares are shown before anything runs — that preview is the real security review step, not a formality.
Publishing it
Once it works, submit it to the Marketplace so other people can find it. This site never hosts or runs your code — a listing is just a reviewed pointer to your manifest URL, checked once at submission time and shown publicly once approved. Anyone who adds it still goes through the exact same permission review described above; a Marketplace listing is a discovery convenience, not a trust shortcut.