This guide covers everything you need to know to develop plugins for VerifyWise.
- Overview
- Plugin Types
- Getting Started
- Backend Plugin Development
- Frontend UI Development
- Registry Configuration
- Testing Your Plugin
- Deployment
- Best Practices
- Troubleshooting
VerifyWise plugins extend the platform's functionality through:
- Backend Logic - Server-side code for integrations, data processing, APIs
- Frontend UI - Dynamic React components injected into the app at runtime
- Configuration - User-configurable settings stored in the database
┌─────────────────────────────────────────────────────────────────────────┐
│ PLUGIN SYSTEM FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. DISCOVERY │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ plugins.json │────►│ PluginService│────►│ Marketplace │ │
│ │ (registry) │ │ (backend) │ │ Page (UI) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ 2. INSTALLATION │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ User clicks │────►│ PluginService│────►│ plugin. │ │
│ │ "Install" │ │ .installPlugin│ │ install() │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Database │ │
│ │ plugin_ │ │
│ │ installations│ │
│ └──────────────┘ │
│ │
│ 3. UI INJECTION (Dynamic, Runtime) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PluginLoader │────►│ Load IIFE │────►│ Register to │ │
│ │ (on startup) │ │ bundle.js │ │ PluginSlots │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ 4. RUNTIME │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PluginSlot │────►│ Render plugin│────►│ Plugin calls │ │
│ │ components │ │ components │ │ backend APIs │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Simple integrations without persistent data storage.
Example: Slack plugin - sends notifications via webhooks, no local tables.
Characteristics:
- No database tables
- Configuration stored in
plugin_installations.configuration - Lightweight, stateless operations
Plugins that create database tables within the tenant's schema.
Example: MLflow plugin - creates mlflow_model_records table.
Characteristics:
- Creates tables in
{tenantId}.table_nameschema - Data isolated per tenant
- Must clean up tables on uninstall
Plugins requiring OAuth authentication with external services.
Example: Slack plugin with OAuth workspace connection.
Characteristics:
- Implements OAuth flow
- Stores tokens securely
- Handles token refresh
- Node.js 18+
- TypeScript 5+
- npm or yarn
- Access to VerifyWise development environment
# Navigate to plugin-marketplace
cd plugin-marketplace
# Create plugin structure
mkdir -p plugins/my-plugin/ui/srcplugins/my-plugin/
├── index.ts # Backend plugin code (REQUIRED)
├── package.json # Backend dependencies (REQUIRED)
├── README.md # Documentation (REQUIRED)
├── tsconfig.json # TypeScript config (OPTIONAL)
└── ui/ # Frontend UI (OPTIONAL)
├── src/
│ ├── index.tsx # Entry point - exports components
│ ├── MyComponent.tsx # Your components
│ └── theme.ts # Optional theming
├── package.json # UI dependencies
├── vite.config.ts # Build configuration
└── tsconfig.json # TypeScript config
Every plugin MUST export these functions:
// plugins/my-plugin/index.ts
// ========== TYPE DEFINITIONS ==========
interface PluginContext {
sequelize: any; // Sequelize instance for database operations
}
interface InstallResult {
success: boolean;
message: string;
installedAt: string;
}
interface UninstallResult {
success: boolean;
message: string;
uninstalledAt: string;
}
interface ValidationResult {
valid: boolean;
errors: string[];
}
interface PluginMetadata {
name: string;
version: string;
author: string;
description: string;
}
// ========== REQUIRED EXPORTS ==========
/**
* Called when plugin is installed
* Use this to create database tables, initialize resources
*/
export async function install(
userId: number,
tenantId: string,
config: Record<string, any>,
context: PluginContext
): Promise<InstallResult> {
try {
const { sequelize } = context;
// Create your tables (for tenant-scoped plugins)
await sequelize.query(`
CREATE TABLE IF NOT EXISTS "${tenantId}".my_plugin_data (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
return {
success: true,
message: "Plugin installed successfully",
installedAt: new Date().toISOString(),
};
} catch (error: any) {
throw new Error(`Installation failed: ${error.message}`);
}
}
/**
* Called when plugin is uninstalled
* Use this to clean up database tables, resources
*/
export async function uninstall(
userId: number,
tenantId: string,
context: PluginContext
): Promise<UninstallResult> {
try {
const { sequelize } = context;
// Drop your tables
await sequelize.query(`
DROP TABLE IF EXISTS "${tenantId}".my_plugin_data CASCADE
`);
return {
success: true,
message: "Plugin uninstalled successfully",
uninstalledAt: new Date().toISOString(),
};
} catch (error: any) {
throw new Error(`Uninstallation failed: ${error.message}`);
}
}
/**
* Validate configuration before saving
* Return errors array with validation messages
*/
export function validateConfig(config: Record<string, any>): ValidationResult {
const errors: string[] = [];
if (!config) {
errors.push("Configuration is required");
return { valid: false, errors };
}
// Add your validation logic
if (!config.api_url) {
errors.push("API URL is required");
}
if (config.api_url && !config.api_url.startsWith("http")) {
errors.push("API URL must start with http:// or https://");
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Plugin metadata - displayed in marketplace
*/
export const metadata: PluginMetadata = {
name: "My Plugin",
version: "1.0.0",
author: "Your Name",
description: "A brief description of what your plugin does",
};/**
* Configure plugin with new settings
* Called when user saves configuration
*/
export async function configure(
userId: number,
tenantId: string,
config: Record<string, any>,
context: PluginContext
): Promise<{ success: boolean; message: string; configuredAt: string }> {
// Validate first
const validation = validateConfig(config);
if (!validation.valid) {
throw new Error(`Invalid configuration: ${validation.errors.join(", ")}`);
}
// Test connection if applicable
const testResult = await testConnection(config);
if (!testResult.success) {
throw new Error(`Connection test failed: ${testResult.message}`);
}
return {
success: true,
message: "Configuration saved successfully",
configuredAt: new Date().toISOString(),
};
}
/**
* Test connection to external service
* Called from "Test Connection" button in UI
*/
export async function testConnection(
config: Record<string, any>
): Promise<{ success: boolean; message: string; testedAt: string }> {
try {
// Test your external service
const response = await fetch(config.api_url + "/health");
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
return {
success: true,
message: "Connection successful",
testedAt: new Date().toISOString(),
};
} catch (error: any) {
return {
success: false,
message: `Connection failed: ${error.message}`,
testedAt: new Date().toISOString(),
};
}
}Plugins can define their own API endpoints via the router export. The backend forwards requests to the appropriate handler based on the route pattern.
// ========== ROUTE HANDLER TYPES ==========
/**
* Context passed to route handlers
*/
interface PluginRouteContext {
tenantId: string;
userId: number;
organizationId: number;
method: string; // HTTP method (GET, POST, PUT, PATCH, DELETE)
path: string; // Route path after /api/plugins/:key/
params: Record<string, string>; // URL params (e.g., { modelId: "123" })
query: Record<string, any>; // Query string params
body: any; // Request body
sequelize: any; // Database connection
configuration: Record<string, any>; // Plugin configuration
}
/**
* Response format for route handlers
*/
interface PluginRouteResponse {
status?: number; // HTTP status code (default 200)
data?: any; // JSON response data
buffer?: any; // Binary data for file downloads
filename?: string; // Filename for Content-Disposition header
contentType?: string; // Custom content type
headers?: Record<string, string>; // Additional response headers
}
// ========== ROUTE HANDLERS ==========
/**
* GET /items - List all items
*/
async function handleGetItems(ctx: PluginRouteContext): Promise<PluginRouteResponse> {
const { sequelize, tenantId } = ctx;
const items = await sequelize.query(
`SELECT * FROM "${tenantId}".my_plugin_data ORDER BY created_at DESC`,
{ type: "SELECT" }
);
return {
status: 200,
data: { items },
};
}
/**
* GET /items/:itemId - Get single item
*/
async function handleGetItemById(ctx: PluginRouteContext): Promise<PluginRouteResponse> {
const { sequelize, tenantId, params } = ctx;
const itemId = params.itemId;
const items = await sequelize.query(
`SELECT * FROM "${tenantId}".my_plugin_data WHERE id = :itemId`,
{ replacements: { itemId }, type: "SELECT" }
);
if (!items || items.length === 0) {
return { status: 404, data: { message: "Item not found" } };
}
return { status: 200, data: items[0] };
}
/**
* POST /items - Create new item
*/
async function handleCreateItem(ctx: PluginRouteContext): Promise<PluginRouteResponse> {
const { sequelize, tenantId, body } = ctx;
const { name, data } = body;
if (!name) {
return { status: 400, data: { message: "Name is required" } };
}
const result = await sequelize.query(
`INSERT INTO "${tenantId}".my_plugin_data (name, data) VALUES (:name, :data) RETURNING *`,
{ replacements: { name, data: JSON.stringify(data || {}) } }
);
return { status: 201, data: result[0][0] };
}
/**
* DELETE /items/:itemId - Delete item
*/
async function handleDeleteItem(ctx: PluginRouteContext): Promise<PluginRouteResponse> {
const { sequelize, tenantId, params } = ctx;
const itemId = params.itemId;
await sequelize.query(
`DELETE FROM "${tenantId}".my_plugin_data WHERE id = :itemId`,
{ replacements: { itemId } }
);
return { status: 200, data: { message: "Item deleted" } };
}
/**
* GET /export - Download data as file
*/
async function handleExport(ctx: PluginRouteContext): Promise<PluginRouteResponse> {
const { sequelize, tenantId } = ctx;
const items = await sequelize.query(
`SELECT * FROM "${tenantId}".my_plugin_data`,
{ type: "SELECT" }
);
const csvContent = "id,name,data\n" + items.map((i: any) =>
`${i.id},${i.name},${JSON.stringify(i.data)}`
).join("\n");
return {
status: 200,
buffer: Buffer.from(csvContent),
filename: "export.csv",
contentType: "text/csv",
};
}
// ========== PLUGIN ROUTER ==========
/**
* Router maps route patterns to handler functions
* Format: "METHOD /path" -> handler function
*
* Examples:
* "GET /items" -> GET /api/plugins/my-plugin/items
* "GET /items/:itemId" -> GET /api/plugins/my-plugin/items/123
* "POST /items" -> POST /api/plugins/my-plugin/items
* "DELETE /items/:itemId" -> DELETE /api/plugins/my-plugin/items/123
*/
export const router: Record<string, (ctx: PluginRouteContext) => Promise<PluginRouteResponse>> = {
"GET /items": handleGetItems,
"GET /items/:itemId": handleGetItemById,
"POST /items": handleCreateItem,
"DELETE /items/:itemId": handleDeleteItem,
"GET /export": handleExport,
};Route Pattern Matching:
| Pattern | Matches | Example |
|---|---|---|
GET /items |
Exact match | GET /api/plugins/my-plugin/items |
GET /items/:itemId |
With param | GET /api/plugins/my-plugin/items/123 → params.itemId = "123" |
POST /oauth/connect |
Nested path | POST /api/plugins/my-plugin/oauth/connect |
DELETE /items/:id/tags/:tagId |
Multiple params | params = { id: "1", tagId: "2" } |
Response Types:
| Type | Fields | Example Use |
|---|---|---|
| JSON | data |
API responses |
| File download | buffer, filename, contentType |
Excel/CSV export |
| Custom headers | headers |
CORS, caching |
{
"name": "@verifywise/plugin-my-plugin",
"version": "1.0.0",
"description": "My Plugin for VerifyWise",
"main": "index.js",
"author": "Your Name",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0"
},
"keywords": [
"verifywise",
"plugin"
]
}// Always use tenantId schema for isolation
await sequelize.query(`
CREATE TABLE IF NOT EXISTS "${tenantId}".my_table (
id SERIAL PRIMARY KEY,
-- your columns
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);const results = await sequelize.query(
`SELECT * FROM "${tenantId}".my_table WHERE status = :status`,
{
replacements: { status: "active" },
type: sequelize.QueryTypes.SELECT,
}
);await sequelize.query(
`INSERT INTO "${tenantId}".my_table (name, data) VALUES (:name, :data)`,
{
replacements: {
name: "Example",
data: JSON.stringify({ key: "value" }),
},
}
);await sequelize.query(`
INSERT INTO "${tenantId}".my_table (id, name, data)
VALUES (:id, :name, :data)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
data = EXCLUDED.data,
updated_at = CURRENT_TIMESTAMP
`, {
replacements: { id: 1, name: "Updated", data: JSON.stringify({}) }
});See Plugin UI Guide for complete UI development documentation.
Plugin UIs are:
- Separate from main app - Not bundled with VerifyWise
- Dynamically loaded - Injected at runtime via
<script>tags - IIFE format - Exposes components on
window.PluginName - Slot-based - Render at predefined injection points
plugins/my-plugin/ui/
├── src/
│ ├── index.tsx # Exports all components
│ ├── MyPluginConfig.tsx # Configuration component
│ └── MyPluginTab.tsx # Tab component
├── package.json
├── vite.config.ts
└── tsconfig.json
// Export all components that will be injected
export { MyPluginConfig } from "./MyPluginConfig";
export { MyPluginTab } from "./MyPluginTab";import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
build: {
lib: {
entry: resolve(__dirname, "src/index.tsx"),
formats: ["iife"],
name: "PluginMyPlugin", // Global variable name
fileName: () => "index.esm.js",
},
rollupOptions: {
// Don't bundle React/MUI - use host app's versions
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@mui/material",
"@emotion/react",
"@emotion/styled",
],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
"react/jsx-runtime": "jsxRuntime",
"@mui/material": "MUI",
"@emotion/react": "emotionReact",
"@emotion/styled": "emotionStyled",
},
},
},
outDir: "dist",
},
});cd plugins/my-plugin/ui
npm install
npm run build
# Output: dist/index.esm.jsAdd your plugin to plugins.json:
{
"key": "my-plugin",
"name": "My Plugin",
"displayName": "My Plugin",
"description": "Short description shown in marketplace card",
"longDescription": "Detailed description shown on plugin detail page. Explain what the plugin does, its benefits, and how to use it.",
"version": "1.0.0",
"author": "Your Name",
"category": "data_management",
"iconUrl": "/assets/my_plugin_logo.svg",
"documentationUrl": "https://docs.example.com/my-plugin",
"supportUrl": "https://support.example.com",
"isOfficial": false,
"isPublished": true,
"requiresConfiguration": true,
"installationType": "tenant_scoped",
"features": [
{
"name": "Feature One",
"description": "What this feature does",
"displayOrder": 1
},
{
"name": "Feature Two",
"description": "What this feature does",
"displayOrder": 2
}
],
"tags": ["keyword1", "keyword2", "keyword3"],
"pluginPath": "plugins/my-plugin",
"entryPoint": "index.ts",
"dependencies": {
"axios": "^1.6.0"
},
"ui": {
"bundleUrl": "/api/plugins/my-plugin/ui/dist/index.esm.js",
"globalName": "PluginMyPlugin",
"slots": [
{
"slotId": "page.plugin.config",
"componentName": "MyPluginConfig",
"renderType": "card"
},
{
"slotId": "page.models.tabs",
"componentName": "MyPluginTab",
"renderType": "tab",
"props": {
"label": "My Plugin Data",
"icon": "Database"
}
}
]
}
}| Field | Type | Required | Description |
|---|---|---|---|
key |
string | Yes | Unique identifier (lowercase, hyphens) |
name |
string | Yes | Display name |
displayName |
string | Yes | Name shown in UI |
description |
string | Yes | Short description (1-2 sentences) |
longDescription |
string | No | Detailed description |
version |
string | Yes | Semantic version (e.g., "1.0.0") |
author |
string | Yes | Author name or organization |
category |
string | Yes | Category ID from categories list |
iconUrl |
string | No | Path to icon SVG |
documentationUrl |
string | No | Link to documentation |
supportUrl |
string | No | Link to support |
isOfficial |
boolean | No | Official VerifyWise plugin |
isPublished |
boolean | Yes | Visible in marketplace |
requiresConfiguration |
boolean | Yes | Shows config panel |
installationType |
string | Yes | standard or tenant_scoped |
features |
array | No | Feature list for detail page |
tags |
array | No | Search keywords |
pluginPath |
string | Yes | Path to plugin directory |
entryPoint |
string | Yes | Main file (index.ts) |
dependencies |
object | No | npm dependencies |
ui |
object | No | UI configuration |
| Field | Type | Required | Description |
|---|---|---|---|
bundleUrl |
string | Yes | URL to IIFE bundle |
globalName |
string | No | Global variable name (default: Plugin + PascalCase key) |
slots |
array | Yes | Slot configurations |
| Field | Type | Required | Description |
|---|---|---|---|
slotId |
string | Yes | Target slot ID |
componentName |
string | Yes | Exported component name |
renderType |
string | Yes | menuitem, modal, tab, card, button, widget, raw |
props |
object | No | Default props for component |
trigger |
string | No | For modals - triggering component name |
# Clone both repositories
git clone <verifywise-repo>
git clone <plugin-marketplace-repo>
# Link plugin-marketplace to verifywise
# In verifywise/Servers/.env:
PLUGIN_MARKETPLACE_PATH=/path/to/plugin-marketplace- Start VerifyWise development server
- Navigate to Integrations page
- Find your plugin in marketplace
- Click "Install"
- Check console for errors
- Go to plugin management page
- Enter configuration values
- Click "Test Connection" (if applicable)
- Click "Save Configuration"
- Verify configuration is saved
- Go to plugin management page
- Click "Uninstall"
- Confirm uninstallation
- Verify tables are dropped (for tenant-scoped)
- Verify UI is removed immediately
- Install plugin again
- Verify UI appears without page refresh
- Verify data tables are recreated
□ Plugin appears in marketplace
□ Plugin card shows correct info
□ Install completes without errors
□ Tables created (tenant-scoped)
□ UI appears after install (no refresh)
□ Configuration form renders
□ Validation errors show correctly
□ Test Connection works
□ Save Configuration works
□ Plugin functionality works
□ Uninstall completes without errors
□ Tables dropped (tenant-scoped)
□ UI removed after uninstall (no refresh)
□ Re-install works correctly
□ No console errors
# Build UI bundle
cd plugins/my-plugin/ui
npm run build
# Verify build output
ls -la dist/
# Should contain index.esm.jsgit add plugins/my-plugin/
git add plugins.json
git commit -m "Add my-plugin integration"
git push origin main# In VerifyWise production environment:
PLUGIN_MARKETPLACE_URL=https://raw.githubusercontent.com/org/plugin-marketplace/main/plugins.json- Check plugin appears in production marketplace
- Test install/uninstall cycle
- Verify UI loads correctly
- Always validate configuration before using it
- Use parameterized queries to prevent SQL injection
- Handle errors gracefully with meaningful messages
- Clean up completely on uninstall
- Use transactions for multi-step operations
- Log important events for debugging
- Use MUI components for consistent styling
- Don't bundle React/MUI - use external
- Handle loading states with spinners
- Show error messages clearly
- Match app styling using same color tokens
- Keep bundles small - only include what's needed
- Provide sensible defaults where possible
- Validate on both client and server
- Use clear field labels and placeholders
- Group related fields together
- Show/hide fields based on other selections
- Never store secrets in code - use configuration
- Validate all user input
- Use HTTPS for external connections
- Sanitize data before database operations
- Don't expose sensitive config in UI
- Check
isPublished: truein plugins.json - Verify plugins.json is valid JSON
- Check VerifyWise can access plugin-marketplace
- Check install() function for errors
- Verify database connection
- Check for duplicate table names
- Look at server logs
- Check bundle builds without errors
- Verify bundleUrl in plugins.json
- Check browser console for script errors
- Verify globalName matches vite.config.ts
- Check PluginLoader is in app
- Verify slot configuration in plugins.json
- Check componentName matches export
- Verify unloadPlugin() is called
- Check PluginRegistry context
- Check validateConfig() returns valid: true
- Verify configure() doesn't throw
- Check server logs for errors
- Verify external service is accessible
- Check authentication credentials
- Look for CORS issues
- Check network/firewall
See these existing plugins for reference:
- MLflow (
plugins/mlflow/) - Tenant-scoped with tabs, configuration - Risk Import (
plugins/risk-import/) - Menu items and modals - Slack (
plugins/slack/) - OAuth-based with webhooks
Each demonstrates different patterns and can be used as templates for your plugin.