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.
- Working example:
examples/hello-plugin - Public contract source:
src/plugin/index.ts - Shared descriptor types:
src/shared/plugin.ts - Host internals and lifecycle:
docs/PLUGIN-ARCHITECTURE.md
| 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 |
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.jsonlist is maintained in the OpenFox repo; arbitrary GitHub URLs, npm packages, and local paths are also accepted. - The manifest
capabilitiesarray 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.
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.
| 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) |
Roots are scanned in order, deduplicated by package name:
{configDir}/plugins/<name>{configDir}/plugins/node_modules/<name>(npm-installed plugins){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.
Same contract as API v1, unchanged:
registry.registerAuth(adapter) // ProviderAuthAdapter
registry.registerTransport(adapter) // ProviderTransportAdapter
registry.registerPreset(preset) // ProviderPresetSee docs/PROVIDER-PLUGINS.md for the full provider
adapter reference. Existing v1 plugins keep working untouched.
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.
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: '...' }),
})namemust 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
allowedToolsto be callable. contextis{ sessionId, workdir, projectId?, signal? }.- Return
{ success, output?, error? }; thrown errors become failed tool results.
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.
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'.
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 (globalby default). - Secret fields (
secret: trueortype: 'password') are never returned in cleartext: reads return them in asecretsSetlist, and an empty submitted value keeps the stored secret. storageKeybacks 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 thesecretsSetflag all follow that row; storage-backed fields are global.- Read them at runtime with
context.settings(scope?, projectId?).
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 },
],
}itemFieldsaccepts the same field types as top-level fields (minuslist,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/maxItemsbound the number of rows; every item is validated againstitemFieldson save (Setting 'registries[0].url' must be a string).
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',
},
},
}hrefis a URL template.{{key}}is replaced by the value ofkey, 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+hrefByValuepick the template from the value of another field of the same row (e.g. asourceselect);hrefis 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 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 referenceplugin:<yourPluginId>:<tabId>for one of the settings tabs you registered. Omittabfor 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.
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.
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.
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.
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).
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.
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.
| 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 |
apiVersion: 2is the current contract. Additive changes are shipped as minor releases and documented inCHANGELOG.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.
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>.
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.
- Name the package
openfox-<name>(scoped names are supported). - Set
"type": "module"and pointopenfox.entryat the compiled ESM file. - Declare
openfox.capabilitiesaccurately — users see them before installing. - Publish to npm, or push the repository to GitHub and install by URL.
- To be listed in the curated registry, open a PR adding an entry to
plugins-registry.jsonwithname,displayName,description, andgithubUrl.
| 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 |