F Treacle

Build a plugin.

One manifest, up to two capabilities: a pane UI renders its own UI inside a pane; a provider becomes a real volume — real tree, real grouping, real drag-to-mutate — for data that isn't a filesystem at all (tickets, issues, whatever you've got an API for). An automation is a related but separate thing — the same sandboxed bridge, running unattended when a file changes, see its own section below. Every kind is just a JSON manifest plus code you host yourself — nothing to upload, nothing to build, nothing Treacle 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. A provider runs inside the exact same sandbox, not a more-trusted one — the only real difference is which direction the calls go (a pane plugin calls the host; the host calls a provider).

The manifest

Every manifest is a plain JSON file declaring one or both capabilities:

{
  "id": "com.example.my-plugin",
  "displayName": "My Plugin",
  "specVersion": 1,
  "entry": "index.html",
  "capabilities": {
    "paneUI": { "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 — always 1 today.
  • entry — for capabilities.paneUI, the HTML file the sandboxed iframe navigates to directly; for capabilities.provider, a JS module the host loads into the sandbox and calls — see Providers. Either way, resolved relative to the manifest's own URL.
  • capabilities.paneUI.permissions — the exact bridge methods a pane-UI plugin is 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: https://app.treacle.cc/example-plugin/manifest.json. Paste that URL into "Add a Plugin" in the app (see "Testing it" below) to see it running for real.

Providers

A provider turns something that isn't a filesystem — Jira tickets, GitHub issues, anything you've got an API for — into a real volume: real TreeView rows, real "group by" facets, real drag-to-mutate. Unlike a pane-UI plugin, a provider has no UI of its own — the app draws the tree — so the calls run the other direction: the host calls you, not the other way around.

{
  "id": "com.example.my-provider",
  "displayName": "My Provider",
  "specVersion": 1,
  "entry": "provider.js",
  "capabilities": {
    "provider": { "track": "web", "read": true, "rename": true, "mutateAttributes": ["status"] }
  },
  "connectFields": [
    { "key": "apiToken", "label": "API token", "type": "password", "required": true }
  ]
}

entry is a plain JS module (not HTML — a provider has no page of its own) exporting manifest and an async connect(fieldValues), which resolves to { ok: true, provider } or { ok: false, error }. provider implements whatever your capabilities.provider declared: list(parent) and stat(node) and read(node) always (that last one returns a real Response); rename, remove, and mutateAttribute only if you declared them. A dropped-onto-a-group action (say, dragging a ticket into a "Done" folder while grouped by status) calls mutateAttribute(node, "status", "Done") — expected to validate the move is actually legal before attempting it, so a real rejection (a workflow that doesn't allow that transition) comes back as a real, specific message instead of a silent no-op. Runs inside the same sandboxed iframe as a pane-UI plugin — your own fetch() calls to your real API work exactly as you'd expect, unmediated by the host.

Two full, real reference providers ship with the app itself — read them rather than guess at the shape: Jira (workflow-gated status transitions, a separate sprint-move API, custom actions) and GitHub Issues (a structurally different one on purpose — an optional credential, a direct attribute write with no workflow lookup, a capability that's genuinely absent rather than unbuilt).

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

  1. Host your manifest.json and entry file anywhere reachable over HTTP(S) — GitHub Pages, your own server, or just a local static file server while you're developing. If it's a provider, make sure that host actually sends CORS headers (most static hosts do by default) — a sandboxed iframe's opaque origin needs them for the dynamic import to succeed at all.
  2. Open the app.
  3. Open the volume picker → "Plugins (experimental)" → "+ Add a Plugin" opens a marketplace browse list first; click "+ Add custom plugin" to paste your own manifest URL instead → Preview → review exactly what it declares → Enable (pane UI) or Connect (provider).
  4. For an automation: Settings → Automations → paste your manifest URL → Preview → Enable.
  5. Either way, exactly what your manifest declares is 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 — both on this site's own Marketplace page and, live, in the app's own "+ Add a Plugin" browse list. Anyone who adds it still goes through the exact same capability review described above; a Marketplace listing is a discovery convenience, not a trust shortcut.