How the OpenFox plugin system works under the hood — for maintainers and plugin authors who want to understand the machinery, not just the API. For the authoring contract (manifest, registry methods, examples), read PLUGINS.md first. This document explains what the host does with what you register.
| Goal | How it is achieved |
|---|---|
| Extend without forking | Every extension point of the app (tools, commands, skills, providers, UI, settings, hooks, workflows, notifications) is contributed through one registry |
| Core stays in control | Plugins can only contribute through PluginRegistry; they cannot patch internals or monkey-patch the server |
| Failure isolation | A plugin that throws during register() or at runtime is reported as a diagnostic and never blocks other plugins or the server |
| No UI breakage | UI contributions are declarative descriptors rendered by the host; plugins never ship React code, so web refactors cannot break the contract |
| Trust transparency | Capabilities are declared in the manifest and shown to users before and after install |
| Dumb client, smart server | All plugin state (registry, settings, UI contributions, notifications) is normalized server-side and streamed to clients in the same shape as REST data |
Plugins run in-process with full Node.js privileges and there is no sandbox — the isolation guarantees are about failure, not security. The canonical trust model (what plugins can do, what the curated registry is for, why project-local plugin code is never loaded) lives in PLUGINS.md § Trust model.
src/
├── plugin/index.ts # Public contract: `openfox/plugin`
│ # PLUGIN_API_VERSION, pluginManifestSchema,
│ # PluginRegistry interface, all plugin types
├── shared/plugin.ts # Wire-format descriptors shared by server + web
│ # (slots, panels, settings schema, notifications)
└── server/plugins/
├── host.ts # PluginHost: lifecycle, install, enable/disable,
│ # RPC dispatch, settings, wiring into the server
├── registry.ts # PluginRegistry: contribution stores, conflict
│ # detection, ownership, contribution summaries
├── loader.ts # Discovery + manifest validation + ESM import
├── install.ts # GitHub / npm / local-path install pipeline
├── hooks.ts # HookBus: event fan-out with timeout isolation
├── hook-emitter.ts # Module-level bridge: server events → HookBus
├── notifications.ts # NotificationService: persist + stream + REST/WS
├── settings.ts # Settings read/write with secret masking
├── model-metadata.ts # Merge plugin metadata into /api/providers
├── transition-handlers.ts # Workflow transition registry (namespaced)
└── asset-auth.ts # Token forwarding for sandboxed iframes
web/src/
├── components/plugins/ # PluginPanelHost, PluginSlot, declarative
│ # panel renderer, settings form, badges
├── lib/plugin-ws.ts # WS integration for plugin UI state
├── lib/plugin-actions.ts # RPC / panel / URL activation dispatch
├── hooks/usePlugins.ts # Plugin state via resource cache
└── stores/pluginUi.ts # Open panels, ui_state values
The split between src/plugin/index.ts and src/shared/plugin.ts is
intentional: the former is the public authoring contract (what a plugin
exports and calls), the latter is the wire format (what server and web
exchange). A plugin type never leaks a server-internal type, and the web never
imports server code.
PluginHost.start()
│
├─ readDisabled() ← settings key 'plugin.disabled' (JSON array)
├─ registry.setReservedToolNames(...) ← built-in tool names can't be shadowed
│
├─ loadPlugins() (loader.ts)
│ │
│ │ Roots scanned in order, dedup by package name:
│ │ 1. {configDir}/plugins/<name>
│ │ 2. {configDir}/plugins/node_modules/<name> (npm installs)
│ │ 3. {cwd}/node_modules/<name> (dev usage)
│ │
│ ├─ for each package dir:
│ │ ├─ readPluginManifest() ← package.json + pluginManifestSchema
│ │ │ (invalid manifest → plugin silently skipped, not an error)
│ │ ├─ shouldLoad? disabled → diagnostic { enabled: false }, skip
│ │ └─ loadPluginFromDirectory()
│ │ ├─ apiVersion must be 1 or 2 → else diagnostic error
│ │ ├─ entry = openfox.entry ?? openfox.plugin
│ │ ├─ registry.beginPlugin(name, context)
│ │ ├─ await import(entryUrl) ← ESM dynamic import
│ │ ├─ module.register must be a function → else diagnostic error
│ │ ├─ await module.register(registry)
│ │ ├─ check registry.getConflicts() → any conflict rejects the
│ │ │ whole plugin (loaded=false, removePlugin, error strings)
│ │ └─ capture deactivate() for later enable/disable cycles
│ │
│ └─ returns PluginDiagnostic[] (one per plugin, loaded or not)
│
├─ records.set(diagnostic) ← host's source of truth per plugin
├─ applyContributions() ← wire everything into the server (below)
└─ setPluginHookEmitter() ← future server events reach the HookBus
Key points:
- Manifest validation is Zod (
pluginManifestSchema): name, version, and theopenfoxobject. The host additionally rejectsapiVersionother than1/2withUnsupported OpenFox plugin API version. - A plugin registers only while
beginPluginis active: the registry trackscurrentPluginIdso every contribution is attributed to its owner, andregistry.contextis only valid duringregister(). - Conflicts reject the whole plugin — duplicate ids across plugins, or a tool colliding with a built-in name, mark the plugin as failed with the conflict strings as its error. This keeps the registry unambiguous.
- Failed loads are non-fatal: the diagnostic (red status in the Plugins tab) is the only consequence. The server and other plugins are unaffected.
enable(id) → re-read manifest at record.source, cache-busted re-import
(entryUrl + '?v=' + Date.now()), register() again, applyContributions()
disable(id) → runDeactivate() (optional plugin hook), registry.removePlugin(id)
(drops every contribution + hook + transition), persist disabled list
uninstall(id) → same as disable + rm -rf the directory (only if under
{configDir}/plugins — node_modules discoveries are not removable)
The disabled list is a single settings row (plugin.disabled, JSON array of
package names) read from the plugin-scoped settings table. Because enable does a
cache-busted re-import, a plugin module is re-executed from scratch — this
is why state must live in context.storage, not module variables.
installFromGithub(url) git clone --depth 1 → {configDir}/plugins/<repo>
installFromNpm(name) npm install --prefix {configDir}/plugins → node_modules/<name>
installFromPath(path) copy (excluding node_modules/.git) → {configDir}/plugins/<basename>
└─ buildIfNeeded(): npm install + npm run build if the
package declares a build script
all three → installFromDirectory()
├─ readPluginManifest() → not an OpenFox plugin → throw
├─ loadPluginFromDirectory({ cacheBust: true })
└─ applyContributions()
Installs happen at runtime; no server restart is needed. The installed package becomes the source of truth in the record, and the plugin is loaded immediately.
After every lifecycle change (start, enable, disable, uninstall, install) the host pushes the aggregated registry state into the server internals:
| Call | Effect |
|---|---|
setPluginTools(...) |
Plugin tools become callable agent tools (Settings → Tools) |
setPluginCommands(...) |
Slash commands join the command registry with pluginId provenance |
setPluginModelMetadataProviders(...) |
Metadata providers merge into /api/providers responses |
refreshSkillSources() |
Calls each source's load(), skills join discovery with source: 'plugin' |
UI contributions, badges, panels, RPC methods, hooks, transitions and assets
are not "applied" — they are read directly from the registry by the routes
(/api/plugins), the agent loop, and the workflow executor.
LLM → tool call 'demo_echo'
→ src/server/tools/index.ts: tool registry finds plugin tool
→ tool.execute(args, { sessionId, workdir, projectId, signal })
→ { success, output } → rendered like any tool result
- Plugin tools must be allow-listed in an agent's
allowedToolsto be callable — same rule as built-ins. - Tool name collisions with built-ins are rejected at registration.
- Thrown errors become
{ success: false, error }results, not crashes.
server event (e.g. EventStore 'chat.done')
→ hook-emitter (setPluginHookEmitter) [module-level bridge]
→ HookBus.emit(event, payload)
→ registry.getHookHandlers(event)
→ Promise.all(handlers, each with 5s timeout)
→ failure/slow → logger.warn, ignored; never blocks the turn
PluginHost.attachEventStore(eventStore) subscribes to all stored events
and maps them to hook events via EVENT_HOOK_MAP (host.ts):
| Stored event | Hook event | Note |
|---|---|---|
chat.done |
turn.completed or workflow.step.completed |
reason === 'step_done' → step |
session.initialized |
session.created |
|
message.done |
message.created |
|
tool.result |
tool.completed |
|
criterion.updated |
criterion.updated |
|
workflow.execution_changed |
workflow.execution.changed |
|
task.completed |
task.completed |
llm.completed is emitted directly from the agent loop (agent-loop.ts, after
each completion attempt). devserver.started, devserver.stopped, and
devserver.state.changed are emitted directly by DevServerManager because
they are process/state events rather than EventStore events. All of them use the
same setPluginHookEmitter bridge.
Hooks are strictly observational — the agent loop never awaits hook results to decide anything.
web action click → POST /api/plugins/<pluginId>/rpc/<method>
→ PluginHost.invokeRpc(pluginId, method, params, ctx)
├─ record exists & enabled
├─ capabilities gate: non-empty list must include 'rpc'
├─ registry.getRpcHandler(pluginId, method) (ownership-checked)
└─ withTimeout(handler(params, ctx), manifest.timeoutMs ?? 30s)
The RPC path is also used by notification actions, panel buttons, iframe pages
and badges with a source: { kind: 'rpc' }.
Slots and rendering are described in the user docs (PLUGINS.md §3 UI). What matters architecturally:
- The web fetches
GET /api/plugins/ui→{ actions, badges, panels, sections }fromregistry.getUiContributions()— ownership stamped on every entry. - Panels support sizes (
'sm','md','lg','xl','full'), rendered viaModalor iframe. - Actions dispatch through
web/src/lib/plugin-actions.ts(rpc/openPanel/openUrl), so a notification action and a header button use the exact same activation contract. - Declarative panels re-render on
plugin.ui_stateWS messages; iframe panels are sandboxed (allow-scripts allow-forms) and, when network auth is enabled, receive the session token as?token=since iframes cannot send headers.
context.notify() (plugin)
→ NotificationService.emit(pluginId, request)
├─ persist (SQLite 'notifications' table)
├─ WS broadcast 'plugin.notification'
└─ toasts + bell + NotificationCenter render the same shape as
GET /api/notifications (streaming/fetch parity)
Notification actions use the same PluginActivation contract as UI actions,
so plugin-actions.ts handles both.
GET /api/providers
→ listProviders()
→ for each provider model: plugin metadata providers consulted (in order)
→ merged into the response (pricing, contextWindow, vision, badges)
→ never mutates persisted configuration
workflow step ends → executor evaluates outgoing transitions
→ { when: { type: 'custom', handler: 'needs_review' } }
→ transition-handlers.ts lookup by '<pluginId>:<name>'
→ handler({ workflowId, stepId, config, outcome, metadataEntries }) → boolean
→ unknown handler or error → false (an 'always' transition can still fire)
Transition handlers are namespaced pluginId:handlerName so two plugins can
use the same short name.
GET /api/plugins/<pluginId>/assets/<path>
→ only paths declared via registerAsset() are served
→ path traversal rejected
→ read-only
- Settings (
settings.ts): plugin-declared schema, values stored per plugin in the DB. Global vs project scope. Secrets (secret: true/type: 'password') are never returned in cleartext — reads return asecretsSetlist, and an empty submitted value keeps the stored secret. - Storage (
context.storage): tiny per-plugin KV store layered on the global settings table with JSON serialization. This is the only state that survives enable/disable cycles, since modules are re-imported on enable.
| State | Location | Sync |
|---|---|---|
| Plugins list + contributions | pluginsResource |
REST + WS after changes |
| UI contributions (slots) | resource over /api/plugins/ui |
on change |
Open panels + ui_state values |
pluginUi store (web) |
WS plugin.ui_state |
| Notifications | notificationsResource |
REST + WS plugin.notification |
| Plugin toasts | pluginToasts store |
ephemeral |
All WS payloads have the same shape as their REST equivalents — the web renders identically whether data arrived streamed or refetched.
apiVersion: 1 plugins (provider-only, legacy) run through the exact same
host and registry — there is no separate v1 code path, and the full
PluginRegistry is passed to their register() at runtime. The provider-only
restriction is a documented contract and TypeScript type
(ProviderPluginRegistry in src/provider/index.ts), not something the host
enforces: a v1 plugin that calls registerTool would not be rejected, it would
simply be violating the contract. Everything else in this document applies
unchanged.
| Capability | Registry method(s) | Surfaces in |
|---|---|---|
providers |
registerAuth, registerTransport, registerPreset |
Provider setup wizard, agent loop |
models |
registerModelMetadataProvider |
Model picker, /api/providers |
tools |
registerTool |
Agent tool list, Settings → Tools |
commands |
registerCommand |
Slash commands, Commands settings |
skills |
registerSkillSource |
Skill discovery (source: 'plugin') |
settings |
registerSettings |
Plugins tab auto-form |
ui |
registerUiAction, registerUiBadge, registerUiPanel |
Header, session, message, composer slots |
hooks |
registerHook |
Background (observational) |
workflows |
registerTransitionHandler |
Workflow when: custom transitions |
rpc |
registerRpc |
POST /api/plugins/<id>/rpc/<method> |
assets |
registerAsset |
/api/plugins/<id>/assets/<path> |
notifications |
context.notify |
Toasts, bell, notification center |
- Authoring contract and examples: PLUGINS.md
- Working minimal plugin:
examples/hello-plugin - Kitchen-sink demo plugin exercising every capability: the
openfox-demo-pluginpackage (companionopenfox-pluginsrepository, maintained alongside OpenFox — install it from a local clone via Settings → Plugins) - Server tests for the host:
src/server/plugins/host.test.ts - E2E:
e2e/plugin-system.test.ts