Plugin System
Guide for extending the Hay plugin system
Plugin System Development Guide #
Guide for extending and improving the Hay plugin system itself
This document is for developers working on the plugin system infrastructure, not for plugin authors. If you're building a plugin, see PLUGIN_API.md instead.
Table of Contents #
- System Architecture
- Adding New Capabilities
- Extending Plugin Types
- Adding Platform APIs
- Improving MCP Support
- Enhancing Security
- Performance Optimization
- Testing Strategy
System Architecture #
Core Services #
Plugin Manager Service #
File: server/services/plugin-manager.service.ts
Responsibilities:
- Discovering plugins from filesystem
- Loading and validating manifests
- Managing plugin registry (database)
- Installation and build orchestration
- Checksum calculation for change detection
Key Methods:
async initialize(): Promise<void>
async registerPlugin(path, sourceType, organizationId): Promise<void>
async installPlugin(pluginId): Promise<void>
async buildPlugin(pluginId): Promise<void>
getPlugin(pluginId): PluginRegistry | undefined
getAllPlugins(): PluginRegistry[]
Initialization Flow:
- Load manifest schema
- Scan
plugins/core/directory - Scan
plugins/custom/{organizationId}/directories - Validate manifests against schema
- Calculate checksums
- Upsert to database
- Load auto-activated plugin routers
Plugin Instance Manager Service #
File: server/services/plugin-instance-manager.service.ts
Responsibilities:
- On-demand instance startup
- Activity tracking and timeout management
- Instance pool management
- Cleanup of inactive instances
Key Methods:
async ensureInstanceRunning(organizationId, pluginId): Promise<void>
async updateActivityTimestamp(organizationId, pluginId): Promise<void>
async cleanupInactiveInstances(): Promise<void>
async stopAllForOrganization(organizationId): Promise<void>
Instance Lifecycle:
graph TD
A[Tool Request] --> B{Instance Running?}
B -->|Yes| C[Update Activity]
B -->|No| D{Pool Available?}
D -->|Yes| E[Start Instance]
D -->|No| F[Queue Request]
F --> G[Wait for Slot]
G --> E
E --> H[Instance Ready]
C --> H
H --> I[Execute Tool]
Process Manager Service #
File: server/services/process-manager.service.ts
Responsibilities:
- Managing MCP server child processes
- Environment variable injection
- Process health monitoring
- Communication setup (stdio/SSE/WebSocket)
- Graceful shutdown
Key Methods:
async startPlugin(organizationId, pluginId): Promise<void>
async stopPlugin(organizationId, pluginId): Promise<void>
isRunning(organizationId, pluginId): boolean
getRunningProcesses(): ProcessInfo[]
MCP Client Factory #
File: server/services/mcp-client-factory.service.ts
Responsibilities:
- Creating MCP clients for local/remote servers
- Transport protocol handling
- Tool invocation
- Connection management
Key Methods:
async createClient(manifest, config): Promise<MCPClient>
async invokeTool(client, toolName, args): Promise<any>
Database Schema #
plugin_registry Table #
Stores plugin metadata:
CREATE TABLE plugin_registry (
id SERIAL PRIMARY KEY,
plugin_id VARCHAR(255) UNIQUE NOT NULL, -- manifest.id
name VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
plugin_path VARCHAR(500) NOT NULL, -- relative path from plugins/
manifest JSONB NOT NULL, -- full manifest
checksum VARCHAR(64) NOT NULL, -- SHA-256 of plugin files
source_type VARCHAR(20) NOT NULL, -- 'core' or 'custom'
organization_id UUID, -- for custom plugins
installed BOOLEAN DEFAULT false,
built BOOLEAN DEFAULT false,
install_error TEXT,
build_error TEXT,
zip_upload_id UUID, -- reference to uploaded ZIP
max_concurrent_instances INTEGER DEFAULT 10,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
plugin_instances Table #
Stores organization-specific plugin instances:
CREATE TABLE plugin_instances (
id SERIAL PRIMARY KEY,
organization_id UUID NOT NULL,
plugin_id INTEGER REFERENCES plugin_registry(id),
enabled BOOLEAN DEFAULT false,
configuration JSONB, -- encrypted secrets
last_activity_at TIMESTAMP,
priority INTEGER DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(organization_id, plugin_id)
);
Request Flow #
Tool Invocation Flow #
sequenceDiagram
participant Client
participant tRPC
participant PluginHandler
participant InstanceManager
participant ProcessManager
participant MCPClient
participant MCPServer
Client->>tRPC: invokeTool(pluginId, tool, args)
tRPC->>PluginHandler: Handle request
PluginHandler->>InstanceManager: ensureInstanceRunning()
InstanceManager->>ProcessManager: Check if running
alt Not Running
InstanceManager->>ProcessManager: startPlugin()
ProcessManager->>MCPServer: Spawn process
MCPServer-->>ProcessManager: Ready
end
PluginHandler->>MCPClient: invokeTool(tool, args)
MCPClient->>MCPServer: MCP Protocol
MCPServer-->>MCPClient: Result
MCPClient-->>PluginHandler: Result
PluginHandler->>InstanceManager: updateActivity()
PluginHandler-->>tRPC: Result
tRPC-->>Client: Result
Adding New Capabilities #
Step-by-Step Process #
1. Update Manifest Schema #
Edit plugins/base/plugin-manifest.schema.json:
{
"capabilities": {
"properties": {
"newCapability": {
"type": "object",
"description": "Description of the new capability",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"config": {
"type": "object"
}
}
}
}
}
}
2. Add TypeScript Types #
Edit server/types/plugin.types.ts:
export interface PluginCapabilities {
mcp?: MCPCapabilities;
ui?: UICapabilities;
api?: APICapabilities;
newCapability?: NewCapability; // Add this
}
export interface NewCapability {
enabled?: boolean;
config?: Record<string, any>;
}
3. Implement Handler Service #
Create server/services/plugin-new-capability.service.ts:
import { HayPluginManifest } from "@server/types/plugin.types";
export class PluginNewCapabilityService {
/**
* Initialize the capability for a plugin
*/
async initializeCapability(
pluginId: string,
manifest: HayPluginManifest,
organizationId: string,
): Promise<void> {
const capability = manifest.capabilities?.newCapability;
if (!capability || !capability.enabled) {
return;
}
// Implementation here
console.log(`Initializing new capability for ${pluginId}`);
}
/**
* Execute the capability
*/
async execute(pluginId: string, params: Record<string, any>): Promise<any> {
// Implementation here
}
}
export const pluginNewCapabilityService = new PluginNewCapabilityService();
4. Integrate with Plugin Manager #
Edit server/services/plugin-manager.service.ts:
import { pluginNewCapabilityService } from './plugin-new-capability.service';
async initialize(): Promise<void> {
// ... existing code
// Initialize new capability for auto-activated plugins
for (const plugin of this.registry.values()) {
const manifest = plugin.manifest as HayPluginManifest;
if (manifest.capabilities?.newCapability?.enabled) {
await pluginNewCapabilityService.initializeCapability(
plugin.pluginId,
manifest,
plugin.organizationId || ''
);
}
}
}
5. Add API Endpoints (if needed) #
Edit server/routes/v1/plugins/plugins.handler.ts:
export const executeNewCapability = authenticatedProcedure
.input(
z.object({
pluginId: z.string(),
params: z.record(z.any()),
}),
)
.mutation(async ({ ctx, input }) => {
return await pluginNewCapabilityService.execute(input.pluginId, input.params);
});
6. Document the Capability #
Update docs/PLUGIN_API.md with:
- Capability description
- Configuration options
- Usage examples
- Best practices
7. Create Example Plugin #
Create example in plugins/core/example-new-capability/:
- manifest.json with the new capability
- Implementation showing best practices
- README.md explaining usage
Extending Plugin Types #
Adding a New Plugin Type #
1. Update Schema Enum #
Edit plugins/base/plugin-manifest.schema.json:
{
"type": {
"type": "array",
"items": {
"type": "string",
"enum": [
"channel",
"mcp-connector",
"retriever",
"playbook",
"workflow",
"analytics",
"new-type" // Add here
]
}
}
}
2. Update TypeScript Types #
export type PluginType =
| "channel"
| "mcp-connector"
| "retriever"
| "playbook"
| "workflow"
| "analytics"
| "new-type"; // Add here
3. Add Type-Specific Behavior #
If the type needs special handling:
// In plugin manager or instance manager
if (manifest.type.includes("new-type")) {
// Special initialization
await this.initializeNewType(plugin);
}
4. Update UI #
Edit dashboard components to display new type:
- Filter options in marketplace
- Type badges
- Type-specific icons
5. Document #
Update documentation:
- Plugin types table in PLUGIN_API.md
- Use cases and examples
- Best practices for this type
Adding Platform APIs #
The permissions.api pattern allows plugins to request access to platform capabilities.
Adding a New Platform API #
1. Define the API Interface #
Create server/services/platform-apis/my-api.service.ts:
/**
* Platform API: My Service
* Plugins must declare "myapi" in permissions.api to use this
*/
export class MyApiService {
/**
* Check if plugin has permission to use this API
*/
private checkPermission(pluginId: string): void {
const plugin = pluginManagerService.getPlugin(pluginId);
const manifest = plugin?.manifest as HayPluginManifest;
if (!manifest.permissions?.api?.includes("myapi")) {
throw new Error(
`Plugin ${pluginId} does not have permission to access MyAPI. ` +
`Add "myapi" to permissions.api in manifest.json`,
);
}
}
/**
* API method that plugins can call
*/
async doSomething(pluginId: string, organizationId: string, params: any): Promise<any> {
this.checkPermission(pluginId);
// Implementation
return { success: true };
}
}
export const myApiService = new MyApiService();
2. Update Manifest Schema #
Add to permissions.api enum:
{
"permissions": {
"properties": {
"api": {
"items": {
"enum": [
"email",
"myapi" // Add here
]
}
}
}
}
}
3. Create tRPC Endpoint (Optional) #
If plugins access via tRPC:
export const myApiAction = authenticatedProcedure
.input(
z.object({
pluginId: z.string(),
params: z.any(),
}),
)
.mutation(async ({ ctx, input }) => {
return await myApiService.doSomething(input.pluginId, ctx.organizationId!, input.params);
});
4. Document the API #
Update PLUGIN_API.md:
### Platform APIs
#### MyAPI
**Permission**: `myapi`
**Description**: Provides access to platform feature X
**Usage**:
\`\`\`json
{
"permissions": {
"api": ["myapi"]
}
}
\`\`\`
**Available Methods**:
- `doSomething(params)` - Does something useful
Improving MCP Support #
Adding a New Transport Protocol #
1. Implement Transport in MCP Client Factory #
Edit server/services/mcp-client-factory.service.ts:
async createClient(
manifest: HayPluginManifest,
config: Record<string, any>
): Promise<MCPClient> {
const transport = manifest.capabilities?.mcp?.transport;
if (transport === 'new-protocol') {
return this.createNewProtocolClient(manifest, config);
}
// ... existing transports
}
private async createNewProtocolClient(
manifest: HayPluginManifest,
config: Record<string, any>
): Promise<MCPClient> {
// Implementation for new protocol
return new MCPClient({
transport: new NewProtocolTransport({
// configuration
})
});
}
2. Update Manifest Schema #
Add to transport enum:
{
"transport": {
"type": "string",
"description": "Transport protocol",
"pattern": "^(stdio|sse|websocket|http|new-protocol)(\\|(stdio|sse|websocket|http|new-protocol))*$"
}
}
3. Test with Real Server #
Create test plugin that uses the new transport.
Adding New Authentication Method #
1. Update Schema #
{
"auth": {
"oneOf": [
{
"type": "array",
"items": {
"enum": ["oauth2", "jwt", "apiKey", "newauth"]
}
}
]
}
}
2. Implement in Process Manager #
Edit server/services/process-manager.service.ts:
private async setupAuthentication(
manifest: HayPluginManifest,
config: Record<string, any>
): Promise<Record<string, string>> {
const auth = manifest.capabilities?.mcp?.auth;
if (Array.isArray(auth) && auth.includes('newauth')) {
return this.setupNewAuth(manifest, config);
}
// ... existing auth methods
}
private async setupNewAuth(
manifest: HayPluginManifest,
config: Record<string, any>
): Promise<Record<string, string>> {
// Return environment variables for auth
return {
AUTH_TYPE: 'newauth',
AUTH_TOKEN: config.newAuthToken,
};
}
3. Document #
Add to authentication section in PLUGIN_API.md.
Enhancing Security #
Sandboxing Plugin Execution #
Consider implementing:
- Process Isolation: Already done via child processes
- Resource Limits: CPU, memory limits per plugin
- Network Restrictions: Whitelist allowed domains
- Filesystem Access: Restrict to plugin directory
- API Rate Limiting: Per plugin, per organization
Audit Logging #
Add audit logs for:
- Plugin installation/uninstallation
- Configuration changes
- Tool invocations
- Permission violations
// Example audit log service
export class PluginAuditService {
async logToolInvocation(
pluginId: string,
organizationId: string,
toolName: string,
args: any,
): Promise<void> {
await auditLogRepository.create({
entityType: "plugin",
entityId: pluginId,
action: "tool_invocation",
organizationId,
metadata: {
toolName,
args: this.sanitizeArgs(args),
},
});
}
private sanitizeArgs(args: any): any {
// Remove sensitive data from args before logging
return args;
}
}
Input Validation #
Strengthen input validation:
import Ajv from "ajv";
export class PluginValidationService {
private ajv: Ajv;
/**
* Validate tool arguments against input schema
*/
validateToolInput(tool: MCPTool, args: any): { valid: boolean; errors?: string[] } {
const validate = this.ajv.compile(tool.input_schema);
const valid = validate(args);
if (!valid) {
return {
valid: false,
errors: validate.errors?.map((e) => e.message),
};
}
return { valid: true };
}
}
Performance Optimization #
Caching Strategies #
1. Manifest Caching #
Cache parsed manifests in memory:
export class PluginManagerService {
private manifestCache: Map<string, HayPluginManifest> = new Map();
getManifest(pluginId: string): HayPluginManifest | undefined {
if (this.manifestCache.has(pluginId)) {
return this.manifestCache.get(pluginId);
}
const plugin = this.registry.get(pluginId);
if (plugin) {
this.manifestCache.set(pluginId, plugin.manifest as HayPluginManifest);
return plugin.manifest as HayPluginManifest;
}
return undefined;
}
}
2. Instance Pooling #
Implement warm instance pools:
export class PluginInstancePoolService {
private warmPools: Map<string, PluginInstance[]> = new Map();
async getOrCreateInstance(pluginId: string): Promise<PluginInstance> {
const pool = this.warmPools.get(pluginId) || [];
if (pool.length > 0) {
return pool.pop()!;
}
return this.createNewInstance(pluginId);
}
async returnToPool(instance: PluginInstance): Promise<void> {
const pool = this.warmPools.get(instance.pluginId) || [];
pool.push(instance);
this.warmPools.set(instance.pluginId, pool);
}
}
3. Tool Response Caching #
Cache idempotent tool results:
export class ToolCacheService {
private cache: Map<string, { result: any; expiry: Date }> = new Map();
getCacheKey(pluginId: string, toolName: string, args: any): string {
return `${pluginId}:${toolName}:${JSON.stringify(args)}`;
}
async getCachedResult(pluginId: string, toolName: string, args: any): Promise<any | null> {
const key = this.getCacheKey(pluginId, toolName, args);
const cached = this.cache.get(key);
if (cached && cached.expiry > new Date()) {
return cached.result;
}
return null;
}
cacheResult(
pluginId: string,
toolName: string,
args: any,
result: any,
ttlSeconds: number = 300,
): void {
const key = this.getCacheKey(pluginId, toolName, args);
const expiry = new Date(Date.now() + ttlSeconds * 1000);
this.cache.set(key, { result, expiry });
}
}
Parallel Processing #
Process multiple tool calls in parallel:
export async function invokeToolsBatch(calls: ToolCall[]): Promise<ToolResult[]> {
return Promise.all(
calls.map((call) =>
pluginInstanceManagerService
.ensureInstanceRunning(call.organizationId, call.pluginId)
.then(() => mcpClientFactory.invokeTool(call.pluginId, call.toolName, call.args)),
),
);
}
Testing Strategy #
Unit Tests #
Test individual services in isolation:
// plugin-manager.service.spec.ts
describe("PluginManagerService", () => {
let service: PluginManagerService;
beforeEach(() => {
service = new PluginManagerService();
});
describe("registerPlugin", () => {
it("should register valid plugin", async () => {
// Test implementation
});
it("should reject invalid manifest", async () => {
// Test implementation
});
});
});
Integration Tests #
Test plugin system end-to-end:
// plugin-system.integration.spec.ts
describe("Plugin System Integration", () => {
it("should enable plugin and invoke tool", async () => {
// 1. Enable plugin
const result = await Hay.plugins.enable.mutate({
pluginId: "test-plugin",
configuration: { apiKey: "test" },
});
expect(result.success).toBe(true);
// 2. Invoke tool
const toolResult = await Hay.plugins.invokeTool.mutate({
pluginId: "test-plugin",
toolName: "test_tool",
arguments: { param: "value" },
});
expect(toolResult).toBeDefined();
});
});
Test Plugin #
Create plugins/test/test-plugin/ for testing:
{
"id": "hay-plugin-test",
"name": "Test Plugin",
"version": "1.0.0",
"description": "Plugin for testing the plugin system",
"author": "Hay",
"type": ["mcp-connector"],
"entry": "./dist/index.js",
"capabilities": {
"mcp": {
"connection": { "type": "local" },
"tools": [
{
"name": "echo",
"description": "Echoes input",
"input_schema": {
"type": "object",
"properties": {
"message": { "type": "string" }
}
}
}
],
"transport": "stdio",
"startCommand": "node mcp/index.js"
}
}
}
Load Testing #
Test plugin system under load:
async function loadTest() {
const concurrentRequests = 100;
const requests = Array(concurrentRequests)
.fill(null)
.map(() =>
Hay.plugins.invokeTool.mutate({
pluginId: "test-plugin",
toolName: "echo",
arguments: { message: "test" },
}),
);
const start = Date.now();
await Promise.all(requests);
const duration = Date.now() - start;
console.log(`Completed ${concurrentRequests} requests in ${duration}ms`);
console.log(`Average: ${duration / concurrentRequests}ms per request`);
}
Monitoring and Observability #
Metrics to Track #
-
Plugin Metrics:
- Active instances per plugin
- Tool invocation count
- Tool invocation latency
- Error rate per tool
- Instance startup time
-
System Metrics:
- Total active instances
- Memory usage per instance
- CPU usage per instance
- Queue depth
- Cache hit rate
Implementing Metrics #
export class PluginMetricsService {
async recordToolInvocation(
pluginId: string,
toolName: string,
duration: number,
success: boolean,
): Promise<void> {
// Record to metrics service (Prometheus, StatsD, etc.)
}
async getPluginMetrics(pluginId: string): Promise<PluginMetrics> {
return {
totalInvocations: 1000,
averageLatency: 250, // ms
errorRate: 0.01, // 1%
activeInstances: 3,
};
}
}
Future Enhancements #
Ideas for Plugin System Evolution #
- Hot Reload: Update plugins without restarting
- Plugin Marketplace: Built-in plugin discovery and installation
- Plugin SDK: NPM package for plugin development
- Visual Plugin Builder: No-code plugin creation UI
- Plugin Dependencies: Plugins that depend on other plugins
- Versioning: Support multiple versions of same plugin
- Rollback: Rollback to previous plugin version
- A/B Testing: Test new plugin versions with subset of users
- Plugin Analytics: Built-in analytics for plugin usage
- Webhooks: Plugin-to-plugin communication via webhooks
Contributing #
When contributing to the plugin system:
- Follow Patterns: Study existing code before adding new features
- Write Tests: Unit and integration tests required
- Document: Update all relevant documentation
- Backward Compatible: Don't break existing plugins
- Performance: Consider impact on system performance
- Security: Security review for new features
Last Updated: 2025-12-03
Version: 1.0.0