@majkapp/plugin-kit
v3.7.12
Published
Pure plugin definition library for MAJK - outputs plugin definitions, not HTTP servers
Maintainers
Readme
@majk/plugin-kit
Fluent builder framework for creating robust MAJK plugins
Build type-safe, production-ready MAJK plugins with excellent developer experience, comprehensive validation, and clear error messages.
Features
✨ Fluent API - Chainable builder pattern with full TypeScript support 🛡️ Type Safety - Compile-time checks for routes, IDs, and descriptions ✅ Build-Time Validation - Catches errors before runtime 📝 Clear Error Messages - Actionable suggestions when things go wrong 🔄 Auto HTTP Server - Built-in routing, CORS, error handling ⚛️ React & HTML Screens - Support for both SPA and simple HTML UIs 🔧 Tool Management - Declare tools with schema validation 💾 Storage Integration - Direct access to plugin storage 📡 Event Bus - Subscribe to system events 🔗 RPC Callbacks - Pass callbacks across plugin boundaries with automatic serialization 🧹 Auto Cleanup - Managed lifecycle with automatic resource cleanup ❤️ Health Checks - Built-in health monitoring
Installation
npm install @majk/plugin-kitQuick Start
import { definePlugin } from '@majk/plugin-kit';
export default definePlugin('my-plugin', 'My Plugin', '1.0.0')
.pluginRoot(__dirname) // REQUIRED: Must be first call
.ui({
appDir: 'dist',
base: '/plugin-screens/my-plugin', // NO trailing slash
history: 'hash'
})
.topbar('/plugin-screens/my-plugin/dashboard', {
icon: '🚀'
})
.screenReact({
id: 'dashboard',
name: 'Dashboard',
description: 'Main dashboard for my plugin. Shows key metrics and actions.',
route: '/plugin-screens/my-plugin/dashboard',
reactPath: '/'
})
.apiRoute({
method: 'GET',
path: '/api/data',
name: 'Get Data',
description: 'Retrieves plugin data. Returns formatted response with metadata.',
handler: async (req, res, { majk, storage }) => {
const data = await storage.get('data') || [];
return { data, count: data.length };
}
})
.tool('global', {
name: 'myTool',
description: 'Does something useful. Processes input and returns results.',
inputSchema: {
type: 'object',
properties: {
param: { type: 'string' }
},
required: ['param']
}
}, async (input, { logger }) => {
logger.info(`Tool called with: ${input.param}`);
return { success: true, result: input.param.toUpperCase() };
})
.build();Core Concepts
Plugin Definition
Every plugin starts with definePlugin(id, name, version) followed immediately by .pluginRoot(__dirname):
definePlugin('system-explorer', 'System Explorer', '1.0.0')
.pluginRoot(__dirname) // REQUIRED: Must be first callRules:
idmust be unique and URL-safe (kebab-case recommended)nameis the display nameversionfollows semver.pluginRoot(__dirname)is REQUIRED - Must be the first call afterdefinePlugin
Screens
React Screens
For React SPAs, configure UI:
.ui({
appDir: 'dist', // Where your built React app is
base: '/plugin-screens/my-plugin', // Base path (NO trailing slash)
history: 'hash' // REQUIRED: Use 'hash' for iframe routing
})
.screenReact({
id: 'dashboard',
name: 'Dashboard',
description: 'Main dashboard view. Shows metrics and controls.', // 2-3 sentences
route: '/plugin-screens/my-plugin/dashboard', // Must start with /plugin-screens/{id}/
reactPath: '/' // Path within your React app
})The React app receives:
window.__MAJK_BASE_URL__- Host base URLwindow.__MAJK_IFRAME_BASE__- Plugin base pathwindow.__MAJK_PLUGIN_ID__- Your plugin ID
HTML Screens
For simple HTML pages:
.screenHtml({
id: 'about',
name: 'About',
description: 'Information about the plugin. Shows version and author.',
route: '/plugin-screens/my-plugin/about',
html: '<html>...' // OR htmlFile: 'about.html'
})API Routes
Define REST endpoints:
.apiRoute({
method: 'POST',
path: '/api/tasks/:id/complete',
name: 'Complete Task',
description: 'Marks a task as complete. Updates task status and triggers notifications.',
handler: async (req, res, { majk, storage, logger }) => {
const { id } = req.params; // Path parameters
const { note } = req.body; // Request body
const status = req.query.get('status'); // Query params
logger.info(`Completing task ${id}`);
// Access MAJK APIs
const todos = await majk.todos.list();
// Use plugin storage
await storage.set(`task:${id}`, { completed: true });
return { success: true, taskId: id };
}
})Available Methods: GET, POST, PUT, PATCH, DELETE
Context Provided:
majk- Full MAJK API interfacestorage- Plugin-scoped key-value storagelogger- Scoped logger (debug, info, warn, error)http- HTTP configuration (port, baseUrl, secret)
Tools
Tools are functions that agents can invoke:
.tool(
'conversation', // Scope: 'global' | 'conversation' | 'teammate' | 'project'
{
name: 'analyzeSentiment',
description: 'Analyzes text sentiment. Returns positive, negative, or neutral classification.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' }
},
required: ['text']
}
},
async (input, { majk, logger }) => {
logger.info('Analyzing sentiment');
// Your implementation
const sentiment = analyzeSentiment(input.text);
return {
success: true,
data: { sentiment, confidence: 0.95 }
};
}
)Tool Scopes:
global- Available everywhereconversation- Scoped to conversationsteammate- Scoped to teammatesproject- Scoped to projects
Entities
Declare entities your plugin provides:
.entity('teammate', [
{
id: 'bot-assistant',
name: 'Bot Assistant',
role: 'bot',
capabilities: ['analysis', 'reporting']
}
])
.entity('mcpServer', [
{
id: 'custom-server',
name: 'Custom MCP Server',
transport: { type: 'stdio', command: 'node', args: ['server.js'] }
}
])Supported Entity Types:
mcpServer- MCP serversteammate- Team members/botsconversation- Conversationstodo- Tasksproject- Projectsagent- AI agents
Config Wizard & Settings
Config Wizard
Show a wizard on first run:
.configWizard({
path: '/setup',
title: 'Initial Setup',
width: 600,
height: 400,
description: 'Configure plugin settings. Set up API keys and preferences.',
shouldShow: async (ctx) => {
const config = await ctx.storage.get('config');
return !config; // Show if no config exists
}
})Settings Screen
Ongoing settings management:
.settings({
path: '/settings',
title: 'Plugin Settings',
description: 'Manage plugin configuration. Adjust behavior and display options.'
})Lifecycle Hooks
onReady
Called after server starts, before onLoad completes:
.onReady(async (ctx, cleanup) => {
// Subscribe to events
const sub = ctx.majk.eventBus.conversations().subscribe((event) => {
ctx.logger.info(`Conversation event: ${event.type}`);
});
cleanup(() => sub.unsubscribe());
// Set up timers
const timer = setInterval(() => {
ctx.logger.debug('Periodic check');
}, 60000);
cleanup(() => clearInterval(timer));
// Any other setup
await loadData(ctx.storage);
})Cleanup Registration:
All cleanup functions are automatically called on onUnload().
Health Checks
Define custom health monitoring:
.health(async ({ majk, storage, logger }) => {
try {
// Check dependencies
await majk.conversations.list();
await storage.get('health-check');
return {
healthy: true,
details: { api: 'ok', storage: 'ok' }
};
} catch (error) {
logger.error(`Health check failed: ${error.message}`);
return {
healthy: false,
details: { error: error.message }
};
}
})API Reference
PluginContext
Provided to all handlers and hooks:
interface PluginContext {
pluginId: string; // Your plugin ID
pluginRoot: string; // Plugin directory path
dataDir: string; // Plugin data directory
app: {
version: string; // MAJK version
name: string; // App name
appDataDir: string; // App data directory
};
http: {
port: number; // Assigned HTTP port
secret: string; // Security secret
baseUrl: string; // Base URL for iframe
};
majk: MajkInterface; // Full MAJK API
storage: PluginStorage; // Key-value storage
logger: PluginLogger; // Scoped logger
timers?: ScopedTimers; // Managed timers
ipc?: ScopedIpcRegistry; // Electron IPC
}MajkInterface
The main MAJK API:
interface MajkInterface {
ai: AIAPI; // AI providers and LLMs
conversations: ConversationAPI;
todos: TodoAPI;
projects: ProjectAPI;
teammates: TeammateAPI;
mcpServers: MCPServerAPI;
knowledge: KnowledgeAPI;
tasks: TaskAPI;
eventBus: EventBusAPI;
auth: AuthAPI;
secrets: SecretsAPI;
plugins: PluginManagementAPI;
}AI API
Access AI providers and language models:
// Get the default LLM
const llm = ctx.majk.ai.getDefaultLLM();
// Send a prompt
const result = await llm.prompt({
messages: [
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'What is 2+2?' }
],
temperature: 0.7,
maxTokens: 100
});
console.log(result.content); // "4"
// Stream responses
const stream = llm.promptStream({
messages: [{ role: 'user', content: 'Tell me a story' }]
});
for await (const chunk of stream) {
if (chunk.type === 'content_delta') {
process.stdout.write(chunk.content);
}
}
// List available providers
const providers = ctx.majk.ai.listProviders();
console.log(`Available: ${providers.map(p => p.name).join(', ')}`);
// Get specific provider
const bedrock = ctx.majk.ai.getProvider('bedrock');
if (bedrock) {
const claude = bedrock.getLLM('anthropic.claude-3-5-sonnet-20241022-v2:0');
// Use Claude...
}
// Query by capability
const imageProviders = ctx.majk.ai.getProvidersWithCapability('imageGeneration');
if (imageProviders.length > 0) {
const image = await imageProviders[0].generateImage({
prompt: 'A beautiful sunset over mountains'
});
}Key Features:
- Provider-agnostic: Works with OpenAI, Anthropic, Bedrock, local models, etc.
- Streaming support: Real-time response streaming
- Function calling: LLM can invoke functions
- Structured output: JSON schema enforcement
- Advanced capabilities: Image generation, embeddings, transcription
- Capability discovery: Query providers by features
Use Cases:
- Add AI features to your plugin
- Create AI-powered tools
- Build custom AI workflows
- Integrate multiple AI providers
- Generate content, analyze data, summarize text
Plugin Management API
Generate authenticated URLs for opening plugin UIs in external browsers or creating cross-plugin links:
// Simple convenience method - get URL for another plugin's screen
const url = await ctx.majk.plugins.getExternalUrl(
'@analytics/dashboard', // Plugin ID (package name format)
'main', // Screen ID (optional)
'#/overview' // Hash fragment (optional)
);
// Opens: http://localhost:9000/plugins/analytics/dashboard/ui/main?token=abc123#/overview
await ctx.shell?.openExternal(url);
// Advanced: Full control over URL generation
const customUrl = await ctx.majk.plugins.getPluginScreenUrl('@myorg/plugin', {
screenId: 'settings', // Specific screen
theme: 'dark', // Theme preference
hash: '#/advanced', // Client-side route
queryParams: { // Custom query parameters
view: 'compact',
filter: 'active'
}
});
// Direct URL with custom path (when you know the exact endpoint)
const apiUrl = await ctx.majk.plugins.getPluginExternalUrl('plugin-id', {
path: '/api/export', // Custom path within plugin
queryParams: { format: 'json' }
});API Methods:
getExternalUrl(pluginId, screenId?, hash?)- Simple convenience method for getting screen URLsgetPluginScreenUrl(pluginId, options)- Screen-aware URL generation with full optionsgetPluginExternalUrl(pluginId, options)- Low-level URL generation for custom paths
URL Format:
http://localhost:{port}/plugins/{org}/{plugin}/ui/{screen}?token={token}&theme={theme}#{hash}Security:
- URLs include one-time authentication tokens (60s TTL)
- After expiration, users are redirected to login if required
- Tokens are automatically validated by the plugin server
Use Cases:
- Open plugin screens in external browser windows
- Create shareable links to plugin functionality
- Cross-plugin navigation and deep linking
- Programmatic browser-based testing
- Integration with external tools and workflows
Example - Cross-Plugin Integration:
.apiRoute({
method: 'POST',
path: '/api/generate-report',
name: 'Generate Report',
description: 'Generates analytics report. Creates report and returns link to view.',
handler: async (req, res, { majk }) => {
// Generate report data
const reportId = await generateReport(req.body);
// Create link to analytics plugin's viewer
const viewerUrl = await majk.plugins.getExternalUrl(
'@analytics/viewer',
'report',
`#/report/${reportId}`
);
return {
success: true,
reportId,
viewUrl: viewerUrl // User can click to view in analytics plugin
};
}
})PluginStorage
Simple key-value storage scoped to your plugin:
interface PluginStorage {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
keys(): Promise<string[]>;
}Example:
// Save data
await storage.set('user-preferences', {
theme: 'dark',
notifications: true
});
// Load data
const prefs = await storage.get<Preferences>('user-preferences');
// List all keys
const keys = await storage.keys();
// Delete specific key
await storage.delete('old-data');
// Clear everything
await storage.clear();EventBus
Subscribe to system events:
// Listen to conversation events
const sub = majk.eventBus.conversations().subscribe((event) => {
console.log(`Event: ${event.type}`, event.entity);
});
// Unsubscribe
sub.unsubscribe();
// Specific event types
majk.eventBus.conversations().created().subscribe(...);
majk.eventBus.conversations().updated().subscribe(...);
majk.eventBus.conversations().deleted().subscribe(...);
// Custom channels
majk.eventBus.channel('my-events').subscribe(...);Validation & Error Handling
Build-Time Validation
The kit validates at build time:
✅ Route Prefixes - Screen routes must match plugin ID ✅ File Existence - React dist and HTML files must exist ✅ Uniqueness - No duplicate routes, tools, or API endpoints ✅ Dependencies - UI must be configured for React screens ✅ Descriptions - Must be 2-3 sentences ending with period
Example Error:
❌ Plugin Build Failed: React screen route must start with "/plugin-screens/my-plugin/"
💡 Suggestion: Change route from "/screens/dashboard" to "/plugin-screens/my-plugin/dashboard"
📋 Context: {
"screen": "dashboard",
"route": "/screens/dashboard"
}Runtime Error Handling
All API route errors are automatically caught and logged:
.apiRoute({
method: 'POST',
path: '/api/process',
name: 'Process Data',
description: 'Processes input data. Validates and transforms the payload.',
handler: async (req, res, { logger }) => {
// Errors are automatically caught and returned as 500 responses
throw new Error('Processing failed');
// Returns:
// {
// "error": "Processing failed",
// "route": "Process Data",
// "path": "/api/process"
// }
}
})Logs show:
❌ POST /api/process - Error: Processing failed
[stack trace]Best Practices
1. Use Storage for State
// ❌ Don't use in-memory state
let cache = {};
// ✅ Use storage
await ctx.storage.set('cache', data);2. Register Cleanups
.onReady(async (ctx, cleanup) => {
// ❌ Don't forget to cleanup
const timer = setInterval(...);
// ✅ Register cleanup
const timer = setInterval(...);
cleanup(() => clearInterval(timer));
// ✅ Event subscriptions
const sub = ctx.majk.eventBus.conversations().subscribe(...);
cleanup(() => sub.unsubscribe());
})3. Validate Input
.apiRoute({
method: 'POST',
path: '/api/create',
name: 'Create Item',
description: 'Creates a new item. Validates input before processing.',
handler: async (req, res) => {
// ✅ Validate input
if (!req.body?.name) {
res.status(400).json({ error: 'Name is required' });
return;
}
// Process...
}
})4. Use Structured Logging
// ❌ Basic logging
logger.info('User action');
// ✅ Structured logging
logger.info('User action', { userId, action: 'create', resourceId });5. Handle Errors Gracefully
.tool('global', spec, async (input, { logger }) => {
try {
const result = await processData(input);
return { success: true, data: result };
} catch (error) {
logger.error(`Tool failed: ${error.message}`);
return {
success: false,
error: error.message,
code: 'PROCESSING_ERROR'
};
}
})Examples
See example.ts for a comprehensive example showing:
- React and HTML screens
- API routes with parameters
- Tools in different scopes
- Entity declarations
- Config wizard
- Event subscriptions
- Storage usage
- Health checks
RPC & Callbacks
Plugins can communicate with each other using RPC services and pass callbacks across plugin boundaries:
Basic RPC Service
// Plugin A: Register a service
.onLoad(async (ctx) => {
await ctx.rpc.registerService('fileProcessor', {
async processFile(path: string, onProgress: (percent: number) => void): Promise<string> {
await onProgress(0);
await onProgress(50);
await onProgress(100);
return `Processed: ${path}`;
}
});
})Consuming with Callbacks
// Plugin B: Use the service with callbacks
.onLoad(async (ctx) => {
const processor = await ctx.rpc.createProxy<{
processFile(path: string, onProgress: (p: number) => void): Promise<string>
}>('fileProcessor');
// Pass callback - it just works!
const result = await processor.processFile('/file.txt', (progress) => {
console.log(`Progress: ${progress}%`);
});
})Explicit Callbacks with Cleanup
For long-lived callbacks (subscriptions, event listeners), use createCallback():
// Auto-cleanup after 10 calls
const callback = await ctx.rpc.createCallback!(
(event) => console.log('Event:', event),
{ maxCalls: 10 }
);
await eventService.subscribe(callback);
// Auto-cleanup after 5 seconds
const callback = await ctx.rpc.createCallback!(
(data) => console.log('Data:', data),
{ timeout: 5000 }
);
await dataStream.subscribe(callback);
// Manual cleanup
const callback = await ctx.rpc.createCallback!((msg) => console.log(msg));
// ... later
ctx.rpc.cleanupCallback!(callback);See docs/RPC_CALLBACKS.md for comprehensive documentation, patterns, and best practices.
TypeScript
Full TypeScript support with:
import {
definePlugin,
FluentBuilder,
PluginContext,
RequestLike,
ResponseLike
} from '@majk/plugin-kit';
// Type-safe plugin ID
const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0');
// ^ Enforces route prefixes
// Type-safe routes
.screenReact({
route: '/plugin-screens/my-plugin/dashboard'
// ^^^^^^^^^ Must match plugin ID
})Troubleshooting
Client Generation Warnings
After running npm run build, check ui/src/generated/generation.log for schema optimization warnings. The generator analyzes your function input schemas and provides actionable suggestions for improving hook performance (e.g., flattening nested structures, reducing large objects).
"React app not built"
❌ Plugin Build Failed: React app not built: /path/to/ui/dist/index.html does not exist
💡 Suggestion: Run "npm run build" in your UI directory to build the React appFix: Build your React app before building the plugin.
"Duplicate API route"
❌ Plugin Build Failed: Duplicate API route: POST /api/dataFix: Each route (method + path) must be unique.
"Description must be 2-3 sentences"
❌ Plugin Build Failed: Description for "My Screen" must be 2-3 sentences, found 1 sentences
💡 Suggestion: Rewrite the description to have 2-3 clear sentences.Fix: Write 2-3 complete sentences ending with periods.
"Tool names must be unique"
❌ Plugin Build Failed: Duplicate tool name: "analyze"Fix: Each tool name must be unique within the plugin.
License
MIT
