Skip to content

Latest commit

 

History

History
760 lines (633 loc) · 32.6 KB

File metadata and controls

760 lines (633 loc) · 32.6 KB

OpenFox Plugins

Plugins let you extend OpenFox without forking it: new LLM providers, tools, slash commands, skills, workflow transitions, settings, notifications, and declarative UI (actions, badges, panels) — all declared through one versioned contract, openfox/plugin.

The core stays in control: plugins can only contribute through the registry. They cannot patch internals, and every contribution is namespaced by plugin id, validated on load, and introspectable from the Plugins settings tab.


1. Concepts

Piece What it is
Manifest openfox field in your package.json: API version, entry point, display name, capabilities
Entry point ESM module exporting register(registry) (and optional deactivate())
Registry The object passed to register(). Every contribution is a method on it
Context registry.context: logger, per-plugin storage, settings reader, notify(), publish()
Host The server-side runtime that loads plugins, validates them, isolates failures, and exposes state to the UI

Trust model

Plugins run in-process with full Node.js privileges, exactly like the provider plugins that came before them. There is no sandbox.

  • Install only plugins you trust.
  • The curated plugins-registry.json list is maintained in the OpenFox repo; arbitrary GitHub URLs, npm packages, and local paths are also accepted.
  • The manifest capabilities array is declarative: the Plugins tab shows it before/after install so users can see what a plugin intends to do.
  • Project-local plugin code (.openfox/plugins/) is deliberately not loaded: cloning a repository must never execute code. Plugin settings can still be project-scoped.

If you need isolation, run untrusted logic in your own process and call it from the plugin — the host only ever invokes your register/RPC/hook functions.


2. Quick start

openfox-hello-plugin/
├── package.json
└── src/index.js
{
  "name": "openfox-hello-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "./src/index.js",
  "openfox": {
    "apiVersion": 2,
    "entry": "./src/index.js",
    "displayName": "Hello Plugin",
    "description": "Adds a hello tool and a header button.",
    "capabilities": ["tools", "ui"],
    "timeoutMs": 15000
  }
}
import type { PluginRegistry } from 'openfox/plugin'

export function register(registry: PluginRegistry) {
  registry.registerTool({
    name: 'hello_greet',
    description: 'Return a greeting.',
    parameters: { type: 'object', properties: { name: { type: 'string' } } },
    execute: async (args) => ({ success: true, output: `Hello, ${args.name ?? 'world'}!` }),
  })

  registry.registerUiAction({
    id: 'hello-open',
    slot: 'header.actions',
    label: { en: 'Say hello', fr: 'Dire bonjour' },
    icon: 'puzzle',
    onActivate: { kind: 'rpc', method: 'greet' },
  })

  registry.registerRpc('greet', async () => {
    registry.context.notify({
      title: { en: 'Hello!', fr: 'Bonjour !' },
      level: 'success',
    })
    return 'ok'
  })
}

Install it from Settings → Plugins (GitHub URL, npm package name, or a local path), enable it, and the tool appears in the agent tool list, the action in the header.

Manifest reference

Field Required Description
openfox.apiVersion yes 1 (providers only, legacy) or 2 (full plugin API)
openfox.entry yes for v2 Path to the ESM entry point, relative to the package root. openfox.plugin is accepted for v1 packages
openfox.displayName no Shown in the Plugins tab. Defaults to the package name
openfox.description no Shown in the Plugins tab
openfox.capabilities no providers, models, settings, tools, commands, skills, ui, hooks, notifications, workflows, rpc, assets, transforms
openfox.timeoutMs no Per-plugin RPC timeout in ms (default 30 000)

Discovery and lifecycle

Roots are scanned in order, deduplicated by package name:

  1. {configDir}/plugins/<name>
  2. {configDir}/plugins/node_modules/<name> (npm-installed plugins)
  3. {cwd}/node_modules/<name>

configDir is ~/.config/openfox/ in production and ~/.config/openfox-dev/ in development. Plugins can be installed, enabled, disabled, and uninstalled at runtime from the Plugins tab — no server restart. Disabling removes every contribution and calls your optional deactivate(); re-enabling re-imports the module.

A plugin that throws during register() is reported as a diagnostic (red status in the Plugins tab) and never blocks other plugins or the server.


3. Contribution reference

Providers (providers)

Same contract as API v1, unchanged:

registry.registerAuth(adapter) // ProviderAuthAdapter
registry.registerTransport(adapter) // ProviderTransportAdapter
registry.registerPreset(preset) // ProviderPreset

See docs/PROVIDER-PLUGINS.md for the full provider adapter reference. Existing v1 plugins keep working untouched.

Model metadata (models)

registry.registerModelMetadataProvider({
  id: 'pricing',
  getMetadata: ({ providerId, modelId, model }) => ({
    pricing: { input: 0.15, output: 0.6, currency: 'USD', discountPercent: 20 },
    contextWindow: 200_000,
    vision: true,
    badges: [{ label: { en: 'Cheap', fr: 'Économique' }, tone: 'success' }],
  }),
})

Metadata is merged into the provider listing returned by /api/providers without mutating persisted configuration, and surfaces in the model picker (price line, capability badge). Return undefined for models you don't cover.

Tools (tools)

registry.registerTool({
  name: 'my_tool',
  description: 'Shown to the LLM.',
  parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
  execute: async (args, context) => ({ success: true, output: '...' }),
})
  • name must be unique; built-in tool names (read_file, run_command, …) cannot be shadowed — the plugin is rejected with a diagnostic.
  • Plugin tools appear in Settings → Tools and must be listed in an agent's allowedTools to be callable.
  • context is { sessionId, workdir, projectId?, signal? }.
  • Return { success, output?, error? }; thrown errors become failed tool results.

Commands (commands)

registry.registerCommand({ id: 'review', name: 'Review', prompt: 'Review {{file}}', agentMode: 'builder' })

Plugin commands merge into the slash-command registry (plugin commands win over defaults with the same id) and carry provenance: /api/commands includes a pluginId field on the command, and the Commands settings list shows a Plugin tag next to it. A user command with the same id shadows the plugin command.

Skills (skills)

registry.registerSkillSource({
  id: 'team-skills',
  label: { en: 'Team skills', fr: 'Compétences d’équipe' },
  load: () => [{ id: 'deploy', name: 'Deploy', description: '…', prompt: '…' }],
})

load() is called on enable and whenever contributions change; loaded skills join the normal skill discovery flow with source: 'plugin'.

Settings (settings)

registry.registerSettings({
  fields: [
    { key: 'endpoint', type: 'text', label: { en: 'Endpoint', fr: 'Endpoint' }, default: 'https://api.example' },
    { key: 'token', type: 'password', label: { en: 'Token', fr: 'Jeton' }, secret: true },
    {
      key: 'mode',
      type: 'select',
      label: { en: 'Mode', fr: 'Mode' },
      options: [
        { value: 'fast', label: { en: 'Fast', fr: 'Rapide' } },
        { value: 'thorough', label: { en: 'Thorough', fr: 'Approfondi' } },
      ],
    },
  ],
})
  • Types: text, password, number, boolean, select, textarea, path, list.
  • A form is auto-rendered in the Plugins tab from the schema — you never write UI code for it.
  • Values are stored per plugin in the database. scope: 'project' on a field stores it per project; otherwise the request scope applies (global by default).
  • Secret fields (secret: true or type: 'password') are never returned in cleartext: reads return them in a secretsSet list, and an empty submitted value keeps the stored secret.
  • storageKey backs a field with the plugin's own storage (context.storage) instead of the settings store — the way to surface a secret an earlier version of the plugin kept in storage, so the field shows as filled instead of empty. Reads, writes and the secretsSet flag all follow that row; storage-backed fields are global.
  • Read them at runtime with context.settings(scope?, projectId?).

Repeatable rows (list)

type: 'list' renders one inline row per item — sub-fields side by side, a remove button per row and an add button below:

{
  key: 'registries',
  type: 'list',
  label: { en: 'Registries', fr: 'Registres' },
  addLabel: { en: 'Add registry', fr: 'Ajouter un registre' },
  removeLabel: { en: 'Remove', fr: 'Supprimer' },
  minItems: 1,
  maxItems: 5,
  default: '[]',
  itemFields: [
    {
      key: 'source',
      type: 'select',
      label: { en: 'Source', fr: 'Source' },
      options: [
        { value: 'github', label: { en: 'GitHub', fr: 'GitHub' } },
        { value: 'gitlab', label: { en: 'GitLab', fr: 'GitLab' } },
      ],
      default: 'github',
    },
    { key: 'url', type: 'text', label: { en: 'Registry URL', fr: 'URL du registre' } },
    { key: 'token', type: 'password', label: { en: 'Token', fr: 'Jeton' }, secret: true },
  ],
}
  • itemFields accepts the same field types as top-level fields (minus list, button, status); sub-field defaults seed a freshly added row.
  • The value is a JSON array string, so context.settings() hands you '[{"source":"github","url":"…","token":"…"}]' — parse it yourself.
  • Secret sub-fields are masked in the settings view like any other secret. A masked or empty submitted value keeps the token stored for the same row index, so removing a row drops its token.
  • minItems / maxItems bound the number of rows; every item is validated against itemFields on save (Setting 'registries[0].url' must be a string).

Open-the-provider-page button (linkButton)

linkButton renders a small "open in a new tab" button next to a field input — typically to send the user to the page where an access token is generated. It works on a top-level field or on a list sub-field:

{
  key: 'token',
  type: 'password',
  label: { en: 'Personal Access Token', fr: 'Jeton d’accès personnel' },
  secret: true,
  linkButton: {
    label: { en: 'Generate a token', fr: 'Générer un jeton' },
    href: 'https://github.com/settings/tokens/new',
    hrefByField: 'source',
    hrefByValue: {
      github: 'https://github.com/settings/tokens/new',
      gitlab: '{{url.origin}}/-/user_settings/personal_access_tokens',
    },
  },
}
  • href is a URL template. {{key}} is replaced by the value of key, and {{key.origin}} by its URL origin (https://gitlab.corp.com/g/r/-/raw/main/index.json → https://gitlab.corp.com) — the way to reach a self-hosted instance.
  • hrefByField + hrefByValue pick the template from the value of another field of the same row (e.g. a source select); href is the fallback.
  • A list sub-field resolves placeholders against its own row (falling back to the top-level fields); a top-level field resolves against the whole form.
  • The button is disabled while the resolved URL is not an absolute http(s) URL, so a template built from a not-yet-filled field stays greyed out.

UI (ui)

UI contributions are declarative descriptors the host renders. Plugins never ship React code, so the contract cannot break when the web UI is refactored. Rich custom UI is supported through sandboxed iframe panels.

Slots

Slot Rendered in
header.actions Top header
session.header.actions Session header
message.actions Message context menu
composer.actions Chat composer, above the input
session.row.badges Session rows in the sidebar
session.header.badges Session header
plugin.menu The plugin's own row in the plugins menu

plugin.menu is the one slot that does not add a row: it takes over the row showing your plugin in the header's plugins menu. label becomes the row label (replacing displayName) and activating the row runs onActivate, so a plugin can send its name anywhere it likes. Without it the row stays an inert group header. Only the first visible plugin.menu action of a plugin is used.

Settings are not a slot: registerSettings() drives the schema-rendered form that appears in the Plugins tab.

Actions

registry.registerUiAction({
  id: 'export',
  slot: 'message.actions',
  label: { en: 'Export message', fr: 'Exporter le message' },
  icon: 'download', // whitelisted: bell, check, download, external,
  // folder, gear, info, play, plus, puzzle,
  // refresh, search, star, terminal, trash, warning
  variant: 'default', // default | primary | danger
  tooltip: { en: '…', fr: '…' },
  visibleWhen: { hasMessage: true }, // declarative visibility, see below
  onActivate: { kind: 'rpc', method: 'export', params: { format: 'md' } },
})

onActivate kinds:

  • { kind: 'rpc', method, params? } — calls your RPC method with the current context (sessionId, workdir, projectId) attached. The RPC handler may return { openPanel: string, content?: DeclarativeNode[] } to immediately open a panel and pre-fill its content in a single round-trip, and/or { invalidate: string[] } to tell the client which cached item lists went stale ('agents', 'commands', 'skills', 'workflows', 'mcpServers'). The named lists are refetched in place, so items written to disk by an RPC (an installed pack, a generated agent, …) show up without a page reload or a server restart.
  • { kind: 'openPanel', panelId } — opens one of your panels.
  • { kind: 'openSettings', tab? } — opens the global settings modal, optionally on a given tab: a core tab id (plugins, tools, skills, …) or a full plugin tab reference plugin:<yourPluginId>:<tabId> for one of the settings tabs you registered. Omit tab for the default tab.
  • { kind: 'openUrl', url } — opens a URL in a new tab.

visibleWhen gates a contribution on the slot context: hasSession, hasProject, hasMessage. Fields are ANDed and omitted fields impose no constraint, so { hasMessage: true } hides the action everywhere except the message context menu. Actions and badges support it; the host filters before rendering.

Badges

Badges may be static or RPC-backed. RPC badges remain backwards-compatible with primitive string/number results, and may also return presentation state (visible, value, label, tone, tooltip, icon) so status indicators can change without core feature-specific UI code.

registry.registerUiBadge({
  id: 'dev-status',
  slot: 'session.row.badges',
  label: { en: 'Dev server', fr: 'Serveur dev' },
  icon: 'M3 4h18v6H3z M3 14h18v6H3z', // named icon or raw SVG path
  appearance: 'icon', // badge | icon
  visibleWhen: { hasSession: true },
  source: {
    kind: 'rpc',
    method: 'status',
    refreshMs: 2000, // optional; minimum effective interval is 250 ms
    cacheScope: 'workdir', // context | session | workdir | project
  },
})

registry.registerRpc('status', async (_params, context) => {
  if (!context.workdir) return { visible: false }
  return {
    visible: true,
    tone: 'success',
    tooltip: { en: 'Dev server running', fr: 'Serveur dev actif' },
  }
})

cacheScope controls single-flight de-duplication when the same contribution is rendered repeatedly. Existing plugins keep the original session-first cache behavior when it is omitted. Session-row badge RPC context includes sessionId, projectId, and the effective workdir.

Panels and Declarative UI

registry.registerUiPanel({
  id: 'quota',
  title: { en: 'Usage & quota', fr: 'Utilisation et quota' },
  size: 'xl', // 'sm' | 'md' | 'lg' | 'xl' (80vw) | '2xl' | '3xl' | 'full' (95vw) (default: 'md')
  kind: 'declarative',
  content: [
    { type: 'text', text: { en: 'Live usage', fr: 'Utilisation en direct' }, muted: false, className: 'mb-2' },
    { type: 'keyValue', items: [{ key: { en: 'Remaining', fr: 'Restant' }, value: '{{tokens}}' }] },
    { type: 'progress', label: { en: 'Budget', fr: 'Budget' }, value: 25, max: 100, tone: 'info' },
    { type: 'table', columns: [{ en: 'Model', fr: 'Modèle' }], rows: [['gpt-x']] },
    { type: 'badge', label: { en: 'Pro', fr: 'Pro' }, tone: 'info', color: '#10b981', className: 'px-2' },
    {
      type: 'button',
      label: { en: 'Refresh', fr: 'Actualiser' },
      title: { en: 'Refresh quota', fr: 'Actualiser le quota' },
      variant: 'default', // 'default' | 'primary' | 'danger' | 'ghost' | 'pill'
      icon: 'refresh',
      disabled: false,
      onActivate: { kind: 'rpc', method: 'refresh' },
    },
    {
      type: 'stack',
      direction: 'row', // 'row' | 'column'
      gap: 'sm', // 'none' | 'xs' | 'sm' | 'md' | 'lg'
      align: 'center', // 'start' | 'center' | 'end' | 'stretch'
      justify: 'between', // 'start' | 'center' | 'end' | 'between'
      children: [
        {
          type: 'input',
          id: 'user-input',
          inputType: 'text', // 'text' | 'number' | 'password' | 'checkbox' | 'textarea'
          label: { en: 'Name', fr: 'Nom' },
          placeholder: { en: 'Enter name...', fr: 'Entrer un nom...' },
          defaultValue: '{{name}}',
          rows: 3, // for textarea
          defaultChecked: false, // for checkbox
          disabled: false,
          onChange: { kind: 'rpc', method: 'updateField' },
          onBlur: { kind: 'rpc', method: 'saveField' },
        },
        {
          type: 'select',
          id: 'category',
          label: { en: 'Category', fr: 'Catégorie' },
          options: [{ value: 'general', label: { en: 'General', fr: 'Général' } }],
          defaultValue: 'general',
          onChange: { kind: 'rpc', method: 'updateCategory' },
        },
      ],
    },
    {
      type: 'card',
      title: { en: 'Details', fr: 'Détails' },
      subtitle: { en: 'Summary', fr: 'Résumé' },
      tone: 'neutral',
      children: [{ type: 'text', text: { en: 'Card body', fr: 'Corps de carte' } }],
    },
    {
      type: 'details',
      title: { en: 'Advanced options', fr: 'Options avancées' },
      defaultOpen: false,
      children: [{ type: 'text', text: { en: 'Hidden content', fr: 'Contenu masqué' } }],
    },
    {
      type: 'callout',
      title: { en: 'Notice', fr: 'Remarque' },
      text: { en: 'Important note', fr: 'Note importante' },
      tone: 'warning',
      icon: 'warning',
    },
    { type: 'icon', icon: 'star', tone: 'warning' },
    { type: 'iframe', url: 'https://example.com/widget', height: 250, width: '100%' },
    { type: 'divider' },
  ],
})

Zones, components and overrides

Zones are named mount points in the core UI. registerUiComponent({ zone, component }) injects a node into a zone; registerUiOverride({ zone, mode, replacement }) replaces (mode: 'replace') or hides (mode: 'hide') the native content of a zone. Both accept visibleWhen, including { eq: { key: value } } matched against the zone context (provider.modal.auth and provider.modal.step2 pass providerId, backend, authAdapter, transportAdapter; providerId is the real provider id, so per-provider data — accounts, tokens — must be scoped by it).

registry.registerUiOverride({
  id: 'my-provider-auth',
  zone: 'provider.modal.auth',
  mode: 'replace',
  visibleWhen: { eq: { transportAdapter: 'my-transport' } },
  replacement: staticShellNode, // provider-agnostic fallback
  contentSource: { kind: 'rpc', method: 'getAuthUi', refreshMs: 3000 },
})

contentSource keeps a zone's content live instead of freezing it at registration time: the host calls the RPC when the contribution mounts and, when refreshMs is set, again on that interval while it stays mounted (cancelled on unmount). The RPC receives the zone context as params (providerId, modelId, tabId, contributionId) plus sessionId/workdir/projectId, and returns { content: DeclarativeNode } or { nodes: DeclarativeNode[] }. Source content wins over component/replacement; a failing call keeps the last rendered content, so the static declaration is only ever the fallback.

Declarative node types: text, keyValue, table, progress, badge, button, toggle, stack, card, callout, icon, details, input, select, iframe, divider. String values may contain {{key}} placeholders filled from values you publish with context.publish(panelId, key, value); published state arrives over WebSocket (plugin.ui_state) and re-renders the open panel.

Panel Lifecycle Hooks:

When a declarative panel or settings tab opens, the host automatically calls the plugin's initPanel RPC method with { panelId: string, tabId?: string } and the effective project/session context (workdir, projectId, sessionId). The RPC can return { content: DeclarativeNode[] } or publish state to dynamically hydrate or refresh content upon modal display. When a panel is opened via an action returning { openPanel, content }, the pre-filled content takes precedence and initPanel is not invoked to prevent accidental overwrites.

Iframe panels:

registry.registerUiPanel({ id: 'board', title: { en: 'Board', fr: 'Tableau' }, kind: 'iframe', url: 'board.html' })
registry.registerAsset('board.html')

The iframe is served from /api/plugins/<id>/assets/<path> with sandbox="allow-scripts allow-forms". Only files you registered with registerAsset are served. When network auth is enabled the host appends the session token as a ?token= query parameter (an iframe cannot send headers), so your page can read it from location.search and call your RPC endpoints itself; otherwise it can poll your RPC methods or receive plugin.ui_state updates by listening on the app's WebSocket.

Notifications (notifications)

context.notify({
  title: { en: 'Build finished', fr: 'Build terminé' },
  body: { en: '3 tests passed', fr: '3 tests réussis' },
  level: 'success', // info | success | warning | error
  actions: [{ label: { en: 'Open report', fr: 'Ouvrir le rapport' }, onActivate: { kind: 'rpc', method: 'report' } }],
})

Notifications are persisted, streamed over WebSocket, rendered as a toast, and listed in the header bell with an unread badge. Streamed and refetched data have identical shape. Each optional action renders as a button in both the toast and the notification center and uses the same onActivate contract as UI actions.

Hooks (hooks)

registry.registerHook('turn.completed', async (payload) => {
  await myTelemetry.record(payload.sessionId, payload.data)
})

Events: session.created, turn.completed, workflow.step.completed (emitted when a turn ends with step_done), workflow.execution.changed (any execution state change, including status and currentStepId), task.completed (a workflow run finished), message.created, tool.completed, llm.completed, criterion.updated, devserver.started, devserver.stopped, devserver.state.changed.

Dev-server hooks are workdir-scoped rather than session-scoped. Their data contains the resolved workdir and url; devserver.started also includes the resolved command and port, while devserver.stopped includes a reason (stop, exit, or error) plus exit/error details when available. devserver.state.changed follows the existing state-change path and reports state (off, running, warning, or error), inspectProxyPort, and errorMessage when present. When OpenFox can resolve the owning project, the normal top-level projectId is included so plugins can read project-scoped settings. The current hook payload contract keeps sessionId as a required field, so these workdir-scoped events emit it as an empty string.

Hooks are observational: they cannot block or alter the agent loop. Each handler runs with a 5 s timeout; a throwing or slow handler is logged and ignored, and never affects the turn.

Workflow transitions (workflows)

registry.registerTransitionHandler('needs_review', async ({ config, outcome }) => {
  return config.route === 'review' && outcome?.result === 'pass'
})

Reference it from a workflow file:

{ "when": { "type": "custom", "handler": "needs_review", "config": { "route": "review" } }, "goto": "review" }

The handler receives { workflowId, stepId, config, outcome, metadataEntries } and must return true to fire the transition. Unknown handlers and errors return false, so a following always transition still provides a fallback.

RPC

registry.registerRpc('quota', async (params, context) => {
  return { remaining: 1234 }
})

Endpoints are POST /api/plugins/<pluginId>/rpc/<method> (authenticated with the same session token as the rest of the API). params is the JSON body's params, and context carries sessionId/workdir. Timeouts come from openfox.timeoutMs (default 30 s) and produce a structured error.

The rpc capability is enforced: if your manifest declares a non-empty capabilities list, it must include "rpc" or the endpoint rejects every call with does not declare the 'rpc' capability. Omitting capabilities entirely keeps the endpoint available (legacy behaviour).

Assets (assets)

registry.registerAsset('board.html')

Files are served read-only from /api/plugins/<id>/assets/<path>; only registered relative paths are reachable, and path traversal is rejected.

Message transforms (transforms)

registry.registerMessageTransform({
  id: 'compressor',
  priority: 50, // optional ordering (lower runs first, default: 100)
  transform: async (messages, context) => {
    // context: { sessionId, projectId?, workdir, model, systemPrompt, mode?, signal? }
    const compressed = await compress(messages, context.model)
    return {
      messages: compressed,
      systemPrompt: context.systemPrompt,
      metadata: { tokensSaved: 150 },
    }
  },
})
  • Transforms intercept and mutate context messages and/or system prompt before dispatch to the LLM.
  • Fail-open resilience: If a transform throws an error or times out (5 s), the core logs a warning and proceeds with uncompressed/unmodified messages without interrupting the turn.
  • Multiple active transforms execute sequentially in priority order.

Context API

Member Description
context.id / context.version Plugin package name and version
context.runtime { mode, configDirectory }
context.logger debug/info/warn/error, prefixed with your plugin id
context.storage Small per-plugin KV store (get/set, JSON-serialized)
context.settings(scope?, projectId?) Resolved settings values
context.notify(request) Emit a notification
context.publish(panelId, key, value) Publish state to your panels

4. Versioning and deprecation

  • apiVersion: 2 is the current contract. Additive changes are shipped as minor releases and documented in CHANGELOG.md.
  • Anything removed or changed incompatibly requires apiVersion: 3; v2 plugins keep loading until a documented removal window.
  • apiVersion: 1 (provider plugins) remains supported; only the three provider registration methods are available to it.
  • The host rejects unknown API versions with a diagnostic instead of guessing.

Contract utilities

The openfox/plugin module also exports the building blocks the host uses, so you can validate a manifest yourself (in a CLI, CI check, or your own installer):

import { PLUGIN_API_VERSION, pluginManifestSchema } from 'openfox/plugin'

PLUGIN_API_VERSION // 2 — the version this OpenFox release implements
const result = pluginManifestSchema.safeParse(pkg)
if (!result.success) console.error(result.error.issues)

pluginManifestSchema validates the name/version/openfox shape (including apiVersion being an integer); the host additionally rejects any apiVersion other than 1 or 2 with Unsupported OpenFox plugin API version: <n>.


5. Testing your plugin

The registry is a plain object, so a fake registry is enough for unit tests — see examples/hello-plugin/src/index.test.ts:

const calls = {}
const registry = {
  context: {/* stubs */},
  registerTool: (tool) => {
    calls.tool = tool
  },
  // …
}
register(registry)

For integration testing, install the plugin into a scratch config directory and start OpenFox with OPENFOX_DEV=true; the Plugins tab shows load diagnostics and the plugin's contribution summary.


6. Publishing

  1. Name the package openfox-<name> (scoped names are supported).
  2. Set "type": "module" and point openfox.entry at the compiled ESM file.
  3. Declare openfox.capabilities accurately — users see them before installing.
  4. Publish to npm, or push the repository to GitHub and install by URL.
  5. To be listed in the curated registry, open a PR adding an entry to plugins-registry.json with name, displayName, description, and githubUrl.

7. Troubleshooting

Symptom Cause
Plugin shows Error with Unsupported OpenFox plugin API version openfox.apiVersion is missing or not 1/2
Plugin does not export register(registry) Entry point must export a named register function
Plugin tool 'x' collides with a built-in tool Rename the tool; built-ins cannot be shadowed
Plugin <kind> 'x' is already registered by 'y' Two plugins claim the same id; ids must be unique per kind
Action/panel missing in the UI Check the plugin is enabled and the slot value is one of the documented slots
RPC returns no RPC method The method name must match registerRpc exactly and the plugin must be enabled
RPC returns does not declare the 'rpc' capability Add "rpc" to openfox.capabilities, or omit the array entirely
Settings save fails with must be a number Values are validated against your schema server-side