Skip to content

Plugin Authoring

nick3 edited this page May 28, 2026 · 1 revision

Plugin Authoring

Drop a .js file into <userData>/clusterspace-data/config/tools/ and ClusterSpace loads it as a tool plugin. Plugins can register new AI tools, override built-in tools, and are hot-reloaded on file change without an app restart.

Source: src/main/ai-tools/plugin-loader.ts.


Where plugins live

<userData>/clusterspace-data/config/tools/
├── my-slack-notify.js
├── my-internal-api.js
└── recipes/                  # subdirs are ignored — only top-level *.js

<userData> paths:

  • Windows: %APPDATA%\ClusterSpace\
  • macOS: ~/Library/Application Support/ClusterSpace/
  • Linux: ~/.config/ClusterSpace/

The tools/ directory is created if missing. Plugins are loaded on app launch and re-loaded on file save (fs.watch with require-cache busting).


Plugin contract

Every plugin must export a register(registry) function. It's called once on load (and again on every reload).

// my-time-tool.js

function register(registry) {
  registry.register({
    name: 'get_current_time',
    description: 'Returns the current ISO timestamp in a specified timezone.',
    parameters: {
      type: 'object',
      properties: {
        timezone: {
          type: 'string',
          description: 'IANA timezone (default UTC)'
        }
      }
    },
    run: async ({ timezone }) => {
      const tz = timezone || 'UTC'
      return {
        now: new Date().toLocaleString('en-US', { timeZone: tz, hour12: false }),
        timezone: tz
      }
    }
  })
}

module.exports = { register }

The registry argument is the live toolRegistry singleton. You can .register, .unregister, .has, etc.


Plugins are full Node

Plugins run in the main process with full Node.js access. They can:

  • Read and write files (require('fs'))
  • Make HTTP requests (require('https') or node-fetch)
  • Spawn child processes (require('child_process'))
  • Use any installed npm module (if require() can find it)
  • Call ClusterSpace internals via ctx.*

This is intentional — plugins are how you extend ClusterSpace without recompiling. Trade-off: only install plugins you trust. A malicious plugin can do anything your user account can.


Useful patterns

Posting to Slack

function register(registry) {
  registry.register({
    name: 'slack_notify',
    description: 'Post a message to a Slack channel via webhook.',
    parameters: {
      type: 'object',
      properties: {
        channel: { type: 'string' },
        text: { type: 'string' }
      },
      required: ['channel', 'text']
    },
    run: async ({ channel, text }) => {
      const url = process.env.SLACK_WEBHOOK_URL
      if (!url) return { success: false, error: 'SLACK_WEBHOOK_URL not set' }
      const res = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ channel, text })
      })
      return res.ok
        ? { success: true }
        : { success: false, error: `HTTP ${res.status}` }
    }
  })
}
module.exports = { register }

Calling an internal API

function register(registry) {
  registry.register({
    name: 'github_get_pr',
    description: 'Fetch a GitHub PR by repo/number.',
    parameters: {
      type: 'object',
      properties: {
        repo: { type: 'string', description: 'owner/name' },
        number: { type: 'number' }
      },
      required: ['repo', 'number']
    },
    run: async ({ repo, number }) => {
      const token = process.env.GITHUB_TOKEN
      const res = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
        headers: token ? { Authorization: `token ${token}` } : {}
      })
      if (!res.ok) return { success: false, error: `HTTP ${res.status}` }
      return await res.json()
    }
  })
}
module.exports = { register }

Using a stored secret

For plugins needing secrets, the recommended path:

  1. Set env vars in the shell that launches ClusterSpace
  2. Read via process.env.MY_SECRET in the plugin

Or, for secrets you want persistent across launches, use a JSON file in <userData>/clusterspace-data/config/ with restrictive file permissions (chmod 600).

ClusterSpace doesn't currently expose safeStorage to plugins — it's reserved for first-party stores (SSH passwords, saved logins, API keys). If you need encrypted-at-rest secrets in plugins, request the API in an issue.

Reading from ClusterSpace stores

Your plugin's run receives ctx as the second argument:

run: async (args, ctx) => {
  const panes = ctx.workspaceStore.get(
    ctx.workspaceStore.getSettings().activeWorkspaceId
  )?.panes ?? []
  // … do something with panes
}

See Tool-Registry for the full list of ctx fields.


Override a built-in tool

Register a tool with the same name as a built-in. The registry warns on overwrite but allows it.

function register(registry) {
  registry.register({
    name: 'browser_navigate',   // overrides the built-in
    description: 'Custom navigate with extra logging.',
    parameters: { /* same shape as built-in */ },
    run: async (args, ctx) => {
      console.log('[my-plugin] navigating:', args.url)
      // … your custom impl
    }
  })
}
module.exports = { register }

This is powerful but easy to misuse — make sure your override implements the same return-value contract the model expects, or things break in subtle ways.


Hot reload

plugin-loader.ts runs fs.watch on the tools/ directory. On any change event:

  1. Bust the require.cache entry for the file
  2. require() again
  3. Call register(toolRegistry) again — overwrites existing registrations with the same names

This works for new files too — drop a .js and it loads without restart. Removing a file does NOT unregister its tools (the registry has no record of which plugin registered which tool); restart to clear.


Risk and policy

Plugin tools have an unknown risk in the Goal-Policy-and-Risk-Levels — they default to network_write (the highest non-money tier). So a goal at risk read_only will prompt for any plugin tool call.

To declare a safer risk for your plugin, currently you'd edit BUILTIN_PERMISSIONS in src/main/goal-policy.ts. A plugin-friendly API for declaring permissions is on the Roadmap.


Debugging plugins

  • Console logs (console.log, console.error) go to the main process console, not the renderer DevTools. Watch the terminal where you ran npm run dev, or look at the Electron main process logs in a packaged build.
  • Throw an error inside run to see it surfaced to the model as a tool error.
  • Use the AI-Chat-Panel to ask "call my_tool with X" and see the model's tool-call card.

Limitations

  • No async register() — registration must be sync (the function can return a promise but it's not awaited; do all work inside run)
  • No way to unregister cleanly when a plugin file is deleted — restart instead
  • No isolation — plugins share the main process's globals
  • No TypeScript out of the box (write in JS or transpile yourself; .ts files are not loaded)

See also

Clone this wiki locally