- Add missing top-level dashboard views section to the PLUGIN_AUTHORING table of contents - Renumber downstream section headings and TOC entries to keep numbering sequential - Add a node:test suite that validates heading order and TOC anchor alignment Fusion-Task-Id: FN-3468
38 KiB
Plugin Authoring Guide
A comprehensive guide to creating Fusion plugins that extend the task board with custom tools, routes, and lifecycle hooks.
Table of Contents
- Getting Started
- Plugin Manifest Reference
- Plugin Settings Schema
- Available Hooks and Signatures
- Registering Tools
- Registering Routes
- Registering UI Slots
- Registering Top-Level Dashboard Views
- Registering Agent Runtimes
- Plugin Context API Reference
- Plugin Lifecycle States
- Testing Plugins
- Publishing Plugins
- Example Plugins
- Registering Skills
- Registering Workflow Steps
- Plugin Prompt Contributions
- Plugin Binary Setup Hooks
1. Getting Started
What Are Fusion Plugins?
Fusion plugins extend the task board with custom functionality:
- Lifecycle Hooks: React to task creation, movement, completion, and errors
- AI Agent Tools: Add custom tools that AI agents can use during task execution
- Custom API Routes: Create dashboard API endpoints for frontend integration
- Settings: Accept user configuration via typed settings schemas
Prerequisites
- Node.js 18+
- TypeScript familiarity
- A Fusion project with the plugin system installed
Quick Start
Create a new plugin using the scaffold command:
fn plugin create my-first-plugin
cd my-first-plugin
pnpm install
pnpm test
Plugin Project Structure
my-plugin/
├── package.json # Plugin metadata + "fusion-plugin" keyword
├── tsconfig.json # TypeScript configuration
├── vitest.config.ts # Test configuration
├── src/
│ ├── index.ts # Plugin entry point (exports default FusionPlugin)
│ └── __tests__/
│ └── index.test.ts # Plugin tests
└── README.md # Plugin documentation
2. Plugin Manifest Reference
The manifest defines your plugin's metadata and capabilities:
import type { PluginManifest } from "@fusion/plugin-sdk";
const manifest: PluginManifest = {
id: "my-custom-plugin", // Unique identifier (kebab-case)
name: "My Custom Plugin", // Human-readable name
version: "1.0.0", // Semver version
description: "Does something useful",
author: "Your Name",
homepage: "https://github.com/you/plugin",
fusionVersion: ">=1.0.0", // Optional: minimum Fusion version
dependencies: [], // Optional: plugin IDs this depends on
settingsSchema: { /* ... */ }, // Optional: configuration schema
runtime: { // Optional: agent runtime metadata
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandbox",
},
};
Fields
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique identifier (kebab-case, validated) |
name |
string | Yes | Human-readable display name |
version |
string | Yes | Semver version (e.g., "1.0.0") |
description |
string | No | Short description |
author |
string | No | Author name or organization |
homepage |
string | No | URL to documentation or repository |
fusionVersion |
string | No | Minimum Fusion version required |
dependencies |
string[] | No | IDs of plugins this depends on |
settingsSchema |
Record<string, PluginSettingSchema> | No | Configuration schema |
runtime |
PluginRuntimeManifestMetadata | No | Agent runtime metadata for discovery |
3. Plugin Settings Schema
Settings allow users to configure your plugin through the dashboard:
import type { PluginSettingSchema } from "@fusion/plugin-sdk";
const settingsSchema: Record<string, PluginSettingSchema> = {
webhookUrl: {
type: "string",
label: "Webhook URL",
description: "URL to send notifications to",
required: true,
},
maxRetries: {
type: "number",
label: "Max Retries",
description: "Maximum number of retry attempts",
defaultValue: 3,
},
enabled: {
type: "boolean",
label: "Enable Feature",
description: "Toggle the feature on/off",
defaultValue: true,
},
severity: {
type: "enum",
label: "Log Severity",
description: "Minimum severity level to log",
enumValues: ["debug", "info", "warn", "error"],
defaultValue: "info",
},
};
Setting Types
| Type | Description | Extra Fields |
|---|---|---|
"string" |
Text input | multiline?: boolean (renders textarea) |
"number" |
Numeric input | — |
"boolean" |
Toggle switch | — |
"enum" |
Dropdown select | enumValues: string[] |
"password" |
Password input (hidden) | — |
"array" |
Dynamic list with add/remove | itemType: "string" | "number" |
Example: All Setting Types
const settingsSchema: Record<string, PluginSettingSchema> = {
// Simple string input
username: {
type: "string",
label: "Username",
description: "Your username",
},
// Multiline text area
message: {
type: "string",
label: "Message",
description: "Multi-line message",
multiline: true,
defaultValue: "Hello!",
},
// Password input (hidden)
apiSecret: {
type: "password",
label: "API Secret",
description: "Your secret key",
},
// Number input
maxRetries: {
type: "number",
label: "Max Retries",
defaultValue: 3,
},
// Boolean toggle
enabled: {
type: "boolean",
label: "Enable Feature",
defaultValue: true,
},
// Dropdown select
severity: {
type: "enum",
label: "Severity",
enumValues: ["debug", "info", "warn", "error"],
defaultValue: "info",
},
// Array of strings
tags: {
type: "array",
label: "Tags",
description: "Tags to track",
itemType: "string",
defaultValue: ["bug", "feature"],
},
// Array of numbers
thresholds: {
type: "array",
label: "Thresholds",
itemType: "number",
defaultValue: [10, 20, 30],
},
};
Accessing Settings
Settings are available in hooks via ctx.settings:
hooks: {
onLoad: (ctx) => {
const webhookUrl = ctx.settings.webhookUrl as string;
if (!webhookUrl) {
ctx.logger.warn("No webhook URL configured");
}
},
},
4. Available Hooks and Signatures
Hooks let your plugin react to events in the Fusion system:
import type { FusionPlugin, PluginContext } from "@fusion/plugin-sdk";
const plugin: FusionPlugin = {
manifest: { /* ... */ },
state: "installed",
hooks: {
onLoad: async (ctx) => {
ctx.logger.info("Plugin loaded!");
},
onTaskCreated: async (task, ctx) => {
ctx.logger.info(`New task: ${task.title}`);
},
// ... other hooks
},
};
Hook Reference
| Hook | Signature | When It Fires |
|---|---|---|
onLoad |
(ctx: PluginContext) => Promise<void> | void |
Plugin first loaded and started |
onUnload |
() => Promise<void> | void |
Plugin stopped/shutdown |
onTaskCreated |
(task: Task, ctx: PluginContext) => Promise<void> | void |
New task created |
onTaskMoved |
(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> | void |
Task moved between columns |
onTaskCompleted |
(task: Task, ctx: PluginContext) => Promise<void> | void |
Task reached "done" |
onError |
(error: Error, ctx: PluginContext) => Promise<void> | void |
Error occurred in plugin execution |
onSchemaInit |
(db: Database) => Promise<void> | void |
After enabled plugins are loaded at startup (engine/daemon/dashboard/serve) |
Hook Behavior
- Timeout: 5 seconds per invocation (logged and skipped if exceeded)
- Error Isolation: Hook failures never block other hooks or abort startup
- Optional: Only define the hooks you need
- Schema hook execution:
onSchemaInithooks run sequentially in plugin dependency order (fromresolveLoadOrder) afterloadAllPlugins(). - Schema hook database API: The hook receives the runtime
Databaseinstance, includingdb.exec()anddb.prepare()for SQL DDL. - Schema hook constraints:
onSchemaInitis intended for idempotent DDL only (CREATE TABLE IF NOT EXISTS,CREATE INDEX IF NOT EXISTS). Avoid data backfills or long-running logic.
Example: Schema initialization hook
hooks: {
onSchemaInit: async (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS plugin_roadmaps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugin_roadmaps_created_at
ON plugin_roadmaps(created_at);
`);
},
},
Example: Notification on Task Completion
hooks: {
onTaskCompleted: async (task, ctx) => {
const webhookUrl = ctx.settings.webhookUrl as string;
if (!webhookUrl) return;
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `✅ Task completed: ${task.title || task.id}`,
}),
});
},
},
5. Registering Tools
Tools extend AI agents with custom capabilities:
import type { FusionPlugin, PluginToolDefinition, PluginToolResult } from "@fusion/plugin-sdk";
const myTool: PluginToolDefinition = {
name: "my_custom_tool",
description: "Does something useful with input text",
parameters: {
type: "object",
properties: {
input: {
type: "string",
description: "The text to process",
},
},
required: ["input"],
},
execute: async (params, ctx) => {
const input = params.input as string;
// Do something useful...
const result = input.toUpperCase();
return {
content: [{ type: "text", text: result }],
};
},
};
const plugin: FusionPlugin = {
manifest: { /* ... */ },
state: "installed",
tools: [myTool],
};
Tool Naming
- Use a unique name prefixed with your plugin ID (e.g.,
my-plugin_action) - Avoid conflicts with built-in tools
Tool Result Format
interface PluginToolResult {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
details?: Record<string, unknown>;
}
6. Registering Routes
Routes create custom API endpoints in the dashboard:
import type { FusionPlugin, PluginRouteDefinition } from "@fusion/plugin-sdk";
const routes: PluginRouteDefinition[] = [
{
method: "GET",
path: "/status",
description: "Get plugin status",
handler: async (req, ctx) => {
return { status: "ok", uptime: process.uptime() };
},
},
{
method: "POST",
path: "/action",
description: "Perform an action",
handler: async (req, ctx) => {
// Access request body
const body = req as { action?: string };
ctx.logger.info(`Action: ${body.action}`);
return { success: true };
},
},
];
const plugin: FusionPlugin = {
manifest: { /* ... */ },
state: "installed",
routes,
};
Route Mounting
Routes are mounted at /api/plugins/{pluginId}/{path}:
- Plugin ID:
fusion-plugin-notification - Route path:
/status - Full URL:
/api/plugins/fusion-plugin-notification/status
Supported Methods
GETPOSTPUTDELETE
7. Registering UI Slots
UI slots are mount points in the Fusion dashboard where plugins can inject custom UI components. Each slot is identified by a unique slotId that corresponds to a specific location in the dashboard UI.
How UI Slots Work
Plugins declare uiSlots in their FusionPlugin definition. The dashboard discovers all registered UI slots via GET /api/plugins/ui-slots and renders matching components at each mount point.
Available Slot IDs
| Slot ID | Location | Description | Status |
|---|---|---|---|
task-detail-tab |
Task detail modal | Tab added to the task detail view | Available |
header-action |
Dashboard header | Action button in the header toolbar | Available |
settings-section |
Settings modal | Section added to the settings panel | Available |
settings-provider-card |
Settings → Authentication | Provider card contribution in Authentication section | Available |
settings-integration-card |
Settings → Authentication | Integration/help card contribution in Authentication section | Available |
onboarding-provider-card |
Onboarding modal → AI setup | Provider card content rendered before host fallback cards | Available |
onboarding-recommendation-card |
Onboarding modal → AI setup | Recommendation/help content rendered near setup intro | Available |
onboarding-setup-help |
Onboarding modal → AI setup | Additional setup-help content rendered below provider sections | Available |
post-onboarding-recommendation |
Dashboard post-onboarding card | Recommendation item rendered in host-owned next-steps container | Available |
task-card-badge |
Task card on the board | Small badge displayed on task cards (e.g., CI status indicator) | Planned |
board-column-footer |
Board column | Footer area below the last card in a column | Planned |
Defining UI Slots
Add uiSlots to your FusionPlugin definition:
import type { FusionPlugin, PluginUiSlotDefinition } from "@fusion/plugin-sdk";
const uiSlots: PluginUiSlotDefinition[] = [
{
slotId: "task-detail-tab",
label: "CI History",
icon: "history",
componentPath: "./components/ci-tab.js",
},
{
slotId: "task-card-badge",
label: "CI Status",
icon: "circle-check",
componentPath: "./components/ci-badge.js",
},
];
const plugin: FusionPlugin = {
manifest: { /* ... */ },
state: "installed",
uiSlots,
hooks: { /* ... */ },
};
PluginUiSlotDefinition Fields
| Field | Type | Required | Description |
|---|---|---|---|
slotId |
string |
Yes | One of the known slot IDs (e.g., "task-detail-tab", "header-action") |
label |
string |
Yes | Human-readable label for the slot |
icon |
string |
No | Lucide icon name for visual identification |
componentPath |
string |
Yes | Path to the JS module exporting the component, relative to the plugin root |
Component Module Format and Host Resolution
componentPath is part of the plugin contract, but dashboard rendering is intentionally host-resolved through a static slot registry (pluginId + slotId + componentPath).
Important implications:
- The dashboard does not load arbitrary plugin modules at runtime from
componentPath. - To render in dashboard flows, your slot entry must match a host-registered mapping.
- Unknown/unmapped entries degrade safely to a visible “missing component” shell (or render nothing when the host sets
renderPlaceholder={false}). - Host flows still own modal structure, navigation, callbacks, and fallback content when no slot entry exists.
For this reason, plugin authors should still provide stable componentPath values in manifests, but coordinate with dashboard host maintainers when adding new UI surfaces or module paths that need mapping.
8. Registering Top-Level Dashboard Views
Top-level views are a sibling contribution type to uiSlots.
uiSlotsare embedded surfaces (task detail tab, header action, etc.)dashboardViewsare full-screen destinations in dashboard navigation
Register dashboardViews on the plugin definition:
import type { PluginDashboardViewDefinition } from "@fusion/plugin-sdk";
const dashboardViews: PluginDashboardViewDefinition[] = [
{
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraphView.tsx",
icon: "Network",
order: 40,
placement: "overflow",
description: "Explore task dependency links",
},
];
PluginDashboardViewDefinition fields
| Field | Type | Required | Description |
|---|---|---|---|
viewId |
string |
Yes | Unique slug-like ID within your plugin namespace |
label |
string |
Yes | Human-readable nav label |
componentPath |
string |
Yes | Module path for the view component, relative to plugin root |
icon |
string |
No | Lucide icon name |
order |
number |
No | Lower values appear earlier in nav |
placement |
"primary" | "overflow" | "more" |
No | Navigation placement hint (default is host-defined overflow behavior) |
description |
string |
No | Short summary for nav/help surfaces |
Current host constraints:
- Discovery API:
GET /api/plugins/dashboard-views - The dashboard does not eval or filesystem-load plugin code in-browser
componentPathis stored for authoring symmetry/future expansion, but render resolution is currently done through a host-side static registry (pluginId + viewId)- Use stable IDs; runtime view key format is
plugin:${pluginId}:${viewId}
9. Registering Agent Runtimes
Plugins can provide custom agent runtime implementations that extend the Fusion engine's ability to execute agent sessions. Runtimes are discovered through the plugin discovery pipeline and can be used by the engine to route agent session creation.
How Runtimes Work
A plugin runtime consists of two parts:
- Runtime Metadata (in manifest): Declares the runtime's identity for discovery
- Runtime Factory (in plugin instance): Creates the runtime instance when needed
Runtime Manifest Metadata
Declare runtime metadata in your plugin's manifest:
import type { PluginManifest } from "@fusion/plugin-sdk";
const manifest: PluginManifest = {
id: "my-runtime-plugin",
name: "My Runtime Plugin",
version: "1.0.0",
runtime: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandboxed environment",
version: "1.0.0",
},
};
PluginRuntimeManifestMetadata Fields
| Field | Type | Required | Description |
|---|---|---|---|
runtimeId |
string |
Yes | Unique runtime identifier within the plugin (kebab-case slug) |
name |
string |
Yes | Human-readable name for the runtime |
description |
string |
No | Short description of what the runtime provides |
version |
string |
No | Semantic version of the runtime implementation |
Runtime Factory
The runtime factory is a function that creates the runtime instance:
import type { FusionPlugin, PluginContext } from "@fusion/plugin-sdk";
const plugin: FusionPlugin = {
manifest: { /* ... */ },
state: "installed",
hooks: {},
runtime: {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandboxed environment",
},
factory: async (ctx: PluginContext) => {
// Initialize the runtime with plugin context
const apiKey = ctx.settings.apiKey as string;
return {
name: "code-interpreter",
version: "1.0.0",
async execute(code: string) {
// Execute code in sandbox
const result = await runSandbox(code, { apiKey });
return result;
},
};
},
},
};
PluginRuntimeFactory Signature
type PluginRuntimeFactory = (ctx: PluginContext) => Promise<unknown> | unknown;
The factory receives PluginContext (same as hooks) and should return the runtime instance. The returned instance's structure depends on the runtime's purpose.
PluginRuntimeRegistration Structure
interface PluginRuntimeRegistration {
metadata: PluginRuntimeManifestMetadata;
factory: PluginRuntimeFactory;
}
Discovery Pipeline
Runtimes are discovered through the plugin discovery pipeline:
- PluginLoader aggregates runtime registrations from all loaded plugins via
getPluginRuntimes() - PluginRunner caches runtime registrations and exposes them via
getPluginRuntimes()
Both components follow the same pattern as tools, routes, and UI slots.
Backwards Compatibility
Runtime registration is entirely optional. Plugins that don't declare a runtime field:
- Continue to work unchanged
- Pass manifest validation
- Don't affect the runtime discovery pipeline
Example: Complete Runtime Plugin
import { definePlugin } from "@fusion/plugin-sdk";
export default definePlugin({
manifest: {
id: "fusion-plugin-code-interpreter",
name: "Code Interpreter Plugin",
version: "1.0.0",
description: "Provides a sandboxed code execution runtime",
runtime: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandboxed environment",
version: "1.0.0",
},
},
state: "installed",
hooks: {
onLoad: async (ctx) => {
ctx.logger.info("Code interpreter runtime plugin loaded");
},
},
runtime: {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandboxed environment",
version: "1.0.0",
},
factory: async (ctx) => {
// Runtime initialization
return {
name: "code-interpreter",
version: "1.0.0",
async execute(code: string, language: string) {
// Sandbox execution logic
return { output: `Executed ${language} code`, result: "success" };
},
};
},
},
} satisfies FusionPlugin);
10. Plugin Context API Reference
The context object is passed to hooks, tools, and route handlers:
interface PluginContext {
pluginId: string;
taskStore: TaskStore;
settings: Record<string, unknown>;
logger: PluginLogger;
emitEvent: (event: string, data: unknown) => void;
createAiSession?: CreateAiSessionFactory;
}
Properties
| Property | Type | Description |
|---|---|---|
pluginId |
string |
Your plugin's unique ID |
taskStore |
TaskStore |
Access to task data (read-only) |
settings |
Record<string, unknown> |
User configuration (merged with defaults) |
logger |
PluginLogger |
Structured logging |
emitEvent |
(event, data) => void |
Emit custom events |
createAiSession |
CreateAiSessionFactory | undefined |
Engine-injected AI session factory (undefined when engine isn't loaded) |
Logger Methods
interface PluginLogger {
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
debug(message: string, ...args: unknown[]): void;
}
createAiSession API
interface CreateAiSessionOptions {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
defaultProvider?: string;
defaultModelId?: string;
}
interface AiSessionResult {
session: {
prompt(text: string): Promise<void>;
state: { messages: Array<{ role: string; content?: unknown }> };
};
sessionFile?: string;
}
type CreateAiSessionFactory = (
options: CreateAiSessionOptions,
) => Promise<AiSessionResult>;
The factory is dependency-injected by the engine at runtime. In test-only or core-only environments where the engine module is not loaded, ctx.createAiSession is undefined, so guard before calling it.
Example: Using ctx.createAiSession()
hooks: {
onLoad: async (ctx) => {
if (!ctx.createAiSession) {
ctx.logger.warn("AI session factory unavailable; engine not loaded");
return;
}
const { session } = await ctx.createAiSession({
cwd: process.cwd(),
systemPrompt: "You are a release assistant for this plugin.",
tools: "readonly",
});
await session.prompt("Summarize what this plugin contributes.");
const latest = session.state.messages.at(-1);
ctx.logger.info("AI summary generated", latest);
},
},
Example: Using the Context
hooks: {
onLoad: (ctx) => {
ctx.logger.info("Plugin starting...");
// Access settings
const apiKey = ctx.settings.apiKey as string;
// Emit custom event
ctx.emitEvent("my-plugin:ready", { timestamp: Date.now() });
},
},
11. Plugin Lifecycle States
Plugins transition through these states:
┌────────────┐
│ installed │ (registered, not loaded)
└─────┬──────┘
│ enable
▼
┌────────────┐
│ started │ ←─────┐ (loaded, hooks active)
└─────┬──────┘ │
│ │ load
│ stop │
▼ │
┌────────────┐ │
│ stopped │ ──────┘
└────────────┘
Any state can transition to:
┌────────────┐
│ error │ (load failure or runtime error)
└────────────┘
State Descriptions
| State | Description |
|---|---|
installed |
Plugin registered but not yet loaded |
started |
Plugin loaded and hooks active |
stopped |
Plugin shut down gracefully |
error |
Plugin failed during load or execution |
12. Testing Plugins
Use Vitest for unit testing your plugins:
Test Structure
// src/__tests__/index.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import plugin from "../index.js";
describe("my plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should export a valid plugin", () => {
expect(plugin.manifest.id).toBe("my-plugin");
expect(plugin.manifest.name).toBeDefined();
});
it("should call onLoad hook", async () => {
const mockCtx = {
pluginId: "my-plugin",
settings: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
emitEvent: vi.fn(),
taskStore: {},
};
await plugin.hooks.onLoad?.(mockCtx as any);
expect(mockCtx.logger.info).toHaveBeenCalled();
});
});
Testing Tools
it("should return correct result from tool", async () => {
const tool = plugin.tools![0];
const mockCtx = { /* ... */ };
const result = await tool.execute({ input: "hello" }, mockCtx as any);
expect(result.content[0].text).toBe("HELLO");
});
Testing Routes
it("should return status from GET /status", async () => {
const route = plugin.routes!.find(r => r.path === "/status");
const req = { params: {}, method: "GET", url: "/status" };
const ctx = { /* ... */ };
const result = await route.handler(req as any, ctx as any);
expect(result).toHaveProperty("status");
});
Running Tests
pnpm test
Fusion Host Regression Coverage (Plugin Discovery / Load / Registration)
When a plugin changes host integration contracts, add or update host-side regression tests in this repository:
- Core loader pipeline (
packages/core/src/__tests__/plugin-loader.test.ts): verifyPluginStore.registerPlugin()→PluginLoader.loadAllPlugins()/loadPlugin()and assertstartedstate transitions, manifest validation failures, disabled-plugin skip behavior, missing entrypoint failures, andonLoaderror handling. - Dashboard API aggregation (
packages/dashboard/src/__tests__/plugin-routes.test.ts,packages/dashboard/src/__tests__/plugin-routes.routes.test.ts): verify plugin visibility viaGET /api/plugins,GET /api/plugins/ui-slots, andGET /api/plugins/runtimesusing standard loader/store aggregation (no plugin-specific route branches). - Dashboard slot consumers (
packages/dashboard/app/components/__tests__/PluginSlot.test.tsx,packages/dashboard/app/hooks/__tests__/usePluginUiSlots.test.ts): cover slot filtering, ordering, and rendering behavior for host slot IDs used by your plugin.
Keep this layer focused on discovery/load/registration plumbing. Deeper feature-flow regressions (Settings UX, onboarding UX, runtime execution/provider behavior) belong in dedicated follow-up suites, not in these plumbing tests.
Runtime/Provider Migration Regression Placement
For runtime-provider migrations (like Droid), use layered regression suites instead of duplicating the same matrix everywhere:
- Engine runtime execution + fallback:
packages/engine/src/__tests__/droid-runtime-e2e.test.ts(patterned after Hermes/OpenClaw/Paperclip E2E suites) verifies plugin runtime resolution + defaultpifallback when missing. - Runtime hint matrix guardrail:
packages/engine/src/__tests__/runtime-selection-regression.test.tskeeps a lightweight hint-to-runtime routing assertion. - Dashboard provider/auth routes:
packages/dashboard/src/__tests__/routes-auth.test.tscoversPOST /api/auth/droid-cli,GET /api/providers/droid-cli/status, and/api/auth/statusreadiness/authenticated surfacing. - Dashboard model filtering + settings hook:
packages/dashboard/src/__tests__/register-model-routes-droid-cli.test.tsandpackages/dashboard/src/__tests__/register-settings-droid-cli.test.tsguarduseDroidClirouting/filter behavior. - Compatibility shim boundaries: if
packages/droid-cliremains, keep tests there focused on delegation to plugin-owned implementations (not a second behavior matrix).
This keeps regressions durable while preserving clear ownership boundaries across engine, dashboard, plugin, and shim layers.
13. Publishing Plugins
Package Requirements
{
"name": "fusion-plugin-my-plugin",
"version": "1.0.0",
"keywords": ["fusion-plugin"],
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
}
},
"peerDependencies": {
"@fusion/core": "workspace:*"
}
}
Publishing Steps
-
Update
package.json:- Set
nametofusion-plugin-*or@scope/fusion-plugin-* - Add
"keywords": ["fusion-plugin"] - Set
"private": false
- Set
-
Build the plugin:
pnpm build -
Publish to npm:
npm publish --access public
Installation
Users can install your plugin via CLI:
fn plugin install fusion-plugin-my-plugin
# or
fn plugin install @scope/fusion-plugin-my-plugin
Or by copying to the plugins directory:
cp -r fusion-plugin-my-plugin ~/.fusion/plugins/
14. Example Plugins
Explore these reference implementations:
Notification Plugin
Sends webhook notifications on task lifecycle events (Slack, Discord, generic HTTP).
- Demonstrates:
onLoad,onTaskCompleted,onTaskMoved,onErrorhooks - Features: Settings schema, webhook formatting, event filtering
Auto-Label Plugin
Automatically labels tasks based on description content using keyword matching.
- Demonstrates:
onTaskCreatedhook, AI agent tools - Features: Text classification, event emission, tool registration
CI Status Plugin
Polls CI status for branches and provides custom API endpoints.
- Demonstrates: Custom routes, periodic background work, route handlers, UI slot registration
- Features:
onLoad/onUnloadlifecycle,setIntervalpolling, REST API, UI slots for task cards and task detail tabs
Droid Runtime Plugin
Reference runtime plugin that migrates a CLI-backed provider into the plugin system.
- Demonstrates: runtime adapter pattern (
runtime-adapter.ts) and plugin-owned streaming/provider orchestration (provider.ts,process-manager.ts) - Demonstrates UI slot registration for
settings-provider-card,settings-integration-card,onboarding-provider-card,onboarding-setup-help, andpost-onboarding-recommendation - Demonstrates dashboard probe delegation through plugin-owned
probeDroidBinary - Preserves provider id
droid-clivia@fusion/droid-clicompatibility shim
Settings Demo Plugin
Example plugin demonstrating settings schema and runtime configuration with all four setting types.
- Demonstrates: Settings schema (string, number, boolean, enum), hooks that read settings, tools with settings-driven output
- Features: Configurable greeting message, tag limit, logging toggle, log level selector
- Install from Settings: Designed to be installed via the dashboard Settings → Plugins UI
Installing Example Plugins from Settings
All example plugins can be installed via the dashboard Settings → Plugins UI:
- Open Fusion dashboard and navigate to Settings (gear icon in header)
- Click Plugins in the sidebar
- Click the Install button
- Enter the absolute path to the plugin directory (e.g.,
/path/to/fusion/plugins/examples/fusion-plugin-settings-demo) - Click Install to register the plugin
- Enable the plugin using the toggle switch
- Configure settings via the settings (gear) icon
- The plugin will reload automatically with new settings
Quick Reference
Minimal Plugin
import { definePlugin } from "@fusion/plugin-sdk";
export default definePlugin({
manifest: {
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
},
state: "installed",
hooks: {
onLoad: (ctx) => {
ctx.logger.info("Hello from my plugin!");
},
},
});
Full Plugin Example
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginContext, PluginUiSlotDefinition } from "@fusion/plugin-sdk";
// UI slots for custom dashboard components
const uiSlots: PluginUiSlotDefinition[] = [
{
slotId: "task-card-badge",
label: "CI Status",
icon: "circle-check",
componentPath: "./components/ci-badge.js",
},
{
slotId: "task-detail-tab",
label: "CI History",
icon: "history",
componentPath: "./components/ci-history-tab.js",
},
];
export default definePlugin({
manifest: {
id: "my-full-plugin",
name: "My Full Plugin",
version: "1.0.0",
description: "A complete example with hooks, tools, routes, UI slots, and runtimes",
settingsSchema: {
apiKey: {
type: "string",
label: "API Key",
required: true,
},
},
},
state: "installed",
tools: [
{
name: "my_tool",
description: "Does something useful",
parameters: {
type: "object",
properties: {
input: { type: "string" },
},
required: ["input"],
},
execute: async (params, ctx) => {
const result = process(params.input as string);
return { content: [{ type: "text", text: result }] };
},
},
],
routes: [
{
method: "GET",
path: "/status",
handler: async () => ({ status: "ok" }),
},
],
uiSlots,
hooks: {
onLoad: (ctx) => ctx.logger.info("Loaded!"),
onTaskCreated: (task, ctx) => {
ctx.logger.info(`Task created: ${task.id}`);
},
onUnload: () => {
// Cleanup
},
},
} satisfies FusionPlugin);
For more information, see the Plugin SDK Reference.
15. Registering Skills
Plugins can contribute reusable skills that are surfaced in agent sessions through Fusion's skill-selection flow.
import type { PluginSkillContribution } from "@fusion/plugin-sdk";
const skills: PluginSkillContribution[] = [
{
skillId: "web-research",
name: "Web Research",
description: "Finds and summarizes web sources for a task",
skillFiles: ["skills/web-research/SKILL.md"],
enabled: true,
triggerPatterns: ["research", "search the web", "find sources"],
},
];
skillFiles are relative to the plugin root. skillId must be kebab-case.
16. Registering Workflow Steps
Plugins can ship workflow step templates that users can enable like built-in quality gates.
import type { PluginWorkflowStepContribution } from "@fusion/plugin-sdk";
const workflowSteps: PluginWorkflowStepContribution[] = [
{
stepId: "strict-review",
name: "Strict Review",
description: "Run an AI review with strict failure criteria",
mode: "prompt",
phase: "pre-merge",
prompt: "Review this task for correctness, regressions, and missing tests.",
toolMode: "readonly",
defaultOn: true,
},
{
stepId: "smoke-build",
name: "Smoke Build",
description: "Build package before merge",
mode: "script",
scriptName: "build",
toolMode: "coding",
},
];
Use mode: "prompt" | "script" and toolMode: "readonly" | "coding".
17. Plugin Prompt Contributions
Prompt contributions let a plugin inject additional instructions into specific prompt surfaces.
Supported surfaces:
executor-systemexecutor-tasktriagereviewerheartbeat
import type { PluginPromptContributions } from "@fusion/plugin-sdk";
const promptContributions: PluginPromptContributions = {
enabledByDefault: false,
contributions: [
{
surface: "reviewer",
position: "append",
content: "Always call out missing tests and unsafe assumptions.",
condition: "Only for backend code changes",
},
],
};
Use enabledByDefault: false when contributions should require explicit opt-in.
18. Plugin Binary Setup Hooks
Plugins can expose setup metadata and lifecycle hooks for optional binaries or runtimes.
import type { PluginSetupCheckResult, PluginSetupHooks, PluginSetupManifest } from "@fusion/plugin-sdk";
const setupManifest: PluginSetupManifest = {
binaryName: "agent-browser",
description: "Headless browser runtime for web-enabled agents",
channel: "stable",
defaultTimeoutMs: 120_000,
};
const setupHooks: PluginSetupHooks = {
async checkSetup(ctx): Promise<PluginSetupCheckResult> {
return { status: "not-installed" };
},
async install(ctx) {
// Use async process execution with timeout; never use execSync.
},
async uninstall(ctx) {
// Remove managed binary/runtime artifacts.
},
};
checkSetup is required. install and uninstall are optional.